feat(desktop-client): add web context menu for bound and workbench windows
Desktop Client Build / Resolve Build Metadata (push) Has been cancelled
Desktop Client Build / Build Desktop Client (push) Has been cancelled
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 22:39:01 +08:00
parent 0b5d8d72f2
commit c28f8218a3
3 changed files with 189 additions and 0 deletions
@@ -72,6 +72,7 @@ import {
} from './session-registry'
import { upsertDesktopAccount } from './transport/api-client'
import { STANDARD_USER_AGENT } from './user-agent'
import { attachWebContextMenu } from './web-context-menu'
import {
getWorkbenchWindowWebContents,
isWorkbenchWindowReadyForDetection,
@@ -4191,6 +4192,7 @@ function createBoundWindow(
})
window.webContents.setUserAgent(STANDARD_USER_AGENT)
attachWebContextMenu(window.webContents)
attachWindowDiagnostics(window, title)
window.webContents.setWindowOpenHandler(({ url }) => {
@@ -0,0 +1,185 @@
import type {
ContextMenuParams,
MenuItemConstructorOptions,
WebContents,
} from 'electron/main'
import { clipboard, shell } from 'electron'
import { Menu } from 'electron/main'
type WebContextMenuOptions = {
allowNavigation?: boolean
}
function isRemoteURL(url: string | undefined): boolean {
return /^https?:\/\//i.test(url ?? '')
}
function hasVisibleText(value: string | undefined): boolean {
return Boolean(value?.trim())
}
function pushSeparator(items: MenuItemConstructorOptions[]): void {
if (items.length > 0 && items.at(-1)?.type !== 'separator') {
items.push({ type: 'separator' })
}
}
function trimTrailingSeparators(items: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] {
while (items.at(-1)?.type === 'separator') {
items.pop()
}
return items
}
function writeClipboardText(text: string): void {
if (text) {
clipboard.writeText(text)
}
}
function buildContextMenuTemplate(
webContents: WebContents,
params: ContextMenuParams,
options: Required<WebContextMenuOptions>,
): MenuItemConstructorOptions[] {
const items: MenuItemConstructorOptions[] = []
const editFlags = params.editFlags
const canCopy = editFlags.canCopy || hasVisibleText(params.selectionText)
const canSelectAll = editFlags.canSelectAll || params.isEditable
if (params.isEditable) {
items.push(
{
label: '撤销',
enabled: editFlags.canUndo,
click: () => webContents.undo(),
},
{
label: '重做',
enabled: editFlags.canRedo,
click: () => webContents.redo(),
},
{ type: 'separator' },
{
label: '剪切',
enabled: editFlags.canCut,
click: () => webContents.cut(),
},
{
label: '复制',
enabled: canCopy,
click: () => webContents.copy(),
},
{
label: '粘贴',
enabled: editFlags.canPaste,
click: () => webContents.paste(),
},
{
label: '删除',
enabled: editFlags.canDelete,
click: () => webContents.delete(),
},
{ type: 'separator' },
{
label: '全选',
enabled: canSelectAll,
click: () => webContents.selectAll(),
},
)
} else if (canCopy) {
items.push({
label: '复制',
click: () => webContents.copy(),
})
}
if (hasVisibleText(params.selectionText)) {
pushSeparator(items)
items.push({
label: '复制所选文字',
click: () => writeClipboardText(params.selectionText),
})
}
if (isRemoteURL(params.linkURL)) {
pushSeparator(items)
items.push(
{
label: '复制链接',
click: () => writeClipboardText(params.linkURL),
},
{
label: '在浏览器中打开链接',
click: () => {
void shell.openExternal(params.linkURL)
},
},
)
}
if (params.mediaType === 'image' && params.hasImageContents) {
pushSeparator(items)
items.push({
label: '复制图片',
click: () => webContents.copyImageAt(params.x, params.y),
})
if (isRemoteURL(params.srcURL)) {
items.push({
label: '复制图片地址',
click: () => writeClipboardText(params.srcURL),
})
}
} else if (isRemoteURL(params.srcURL)) {
pushSeparator(items)
items.push({
label: '复制媒体地址',
click: () => writeClipboardText(params.srcURL),
})
}
if (options.allowNavigation) {
pushSeparator(items)
items.push(
{
label: '后退',
enabled: webContents.navigationHistory.canGoBack(),
click: () => webContents.navigationHistory.goBack(),
},
{
label: '前进',
enabled: webContents.navigationHistory.canGoForward(),
click: () => webContents.navigationHistory.goForward(),
},
{
label: '重新加载',
enabled: !webContents.isDestroyed(),
click: () => webContents.reload(),
},
)
}
return trimTrailingSeparators(items)
}
export function attachWebContextMenu(
webContents: WebContents,
options: WebContextMenuOptions = {},
): void {
const resolvedOptions: Required<WebContextMenuOptions> = {
allowNavigation: options.allowNavigation ?? true,
}
webContents.on('context-menu', (event, params) => {
const template = buildContextMenuTemplate(webContents, params, resolvedOptions)
if (template.length === 0) {
return
}
event.preventDefault()
Menu.buildFromTemplate(template).popup({
frame: params.frame ?? undefined,
sourceType: params.menuSourceType,
})
})
}
@@ -4,6 +4,7 @@ import type { Session, WebContents } from 'electron/main'
import { BrowserWindow, WebContentsView, ipcMain, nativeTheme } from 'electron/main'
import { STANDARD_USER_AGENT } from './user-agent'
import { attachWebContextMenu } from './web-context-menu'
interface WorkbenchWindowOptions {
title: string
@@ -817,6 +818,7 @@ export async function openWorkbenchWindow(options: WorkbenchWindowOptions): Prom
})
view.webContents.setUserAgent(STANDARD_USER_AGENT)
attachWebContextMenu(view.webContents)
window.contentView.addChildView(view)
const record: WorkbenchWindowRecord = {