diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index baa474b..f9d29d8 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -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 }) => { diff --git a/apps/desktop-client/src/main/web-context-menu.ts b/apps/desktop-client/src/main/web-context-menu.ts new file mode 100644 index 0000000..ca2ea62 --- /dev/null +++ b/apps/desktop-client/src/main/web-context-menu.ts @@ -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, +): 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 = { + 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, + }) + }) +} diff --git a/apps/desktop-client/src/main/workbench-window.ts b/apps/desktop-client/src/main/workbench-window.ts index 1b5b662..37410f8 100644 --- a/apps/desktop-client/src/main/workbench-window.ts +++ b/apps/desktop-client/src/main/workbench-window.ts @@ -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 = {