Skip to content

Commit 3ef27c1

Browse files
committed
feat: add initial chrome.contextMenus impl
1 parent 5a4d6d8 commit 3ef27c1

File tree

10 files changed

+241
-46
lines changed

10 files changed

+241
-46
lines changed

packages/electron-chrome-extensions/src/browser/api-state.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ export class ExtensionAPIState {
1212
}
1313

1414
getTabById(tabId: number) {
15-
return Array.from(this.tabs).find((tab) => tab.id === tabId)
15+
return Array.from(this.tabs).find((tab) => !tab.isDestroyed() && tab.id === tabId)
1616
}
1717
}

packages/electron-chrome-extensions/src/browser/api/browser-action.ts

+4-20
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { session, ipcMain, nativeImage } from 'electron'
1+
import { session, ipcMain } from 'electron'
22
import { EventEmitter } from 'events'
3-
import * as path from 'path'
3+
import { getIconImage } from './common'
44

55
interface ExtensionAction {
66
backgroundColor?: string
@@ -71,22 +71,6 @@ export class BrowserActionAPI extends EventEmitter {
7171
return action
7272
}
7373

74-
private processIcon(extension: Electron.Extension) {
75-
const { browser_action } = extension.manifest
76-
const { default_icon } = browser_action
77-
78-
if (typeof default_icon === 'string') {
79-
const iconPath = path.join(extension.path, default_icon)
80-
const image = nativeImage.createFromPath(iconPath)
81-
return image.toDataURL()
82-
} else if (typeof default_icon === 'object') {
83-
const key = Object.keys(default_icon).pop() as any
84-
const iconPath = path.join(extension.path, default_icon[key])
85-
const image = nativeImage.createFromPath(iconPath)
86-
return image.toDataURL()
87-
}
88-
}
89-
9074
getPopupPath(session: Electron.Session, extensionId: string, tabId: string) {
9175
const action = this.getAction(session, extensionId)
9276
return action.tabs[tabId]?.popup?.path
@@ -101,8 +85,8 @@ export class BrowserActionAPI extends EventEmitter {
10185

10286
action.title = browser_action.default_title || manifest.name
10387

104-
const icon = this.processIcon(extension)
105-
if (icon) action.icon = icon
88+
const iconImage = getIconImage(extension)
89+
if (iconImage) action.icon = iconImage.toDataURL()
10690
}
10791
}
10892

packages/electron-chrome-extensions/src/browser/api/common.ts

+21-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { BrowserWindow } from 'electron'
1+
import * as path from 'path'
2+
import { BrowserWindow, nativeImage } from 'electron'
23

34
export interface TabContents extends Electron.WebContents {
45
favicon?: string
@@ -14,3 +15,22 @@ export const getParentWindowOfTab = (tab: TabContents): BrowserWindow | null =>
1415
}
1516
return null
1617
}
18+
19+
export const getIconPath = (extension: Electron.Extension) => {
20+
const { browser_action } = extension.manifest
21+
const { default_icon } = browser_action
22+
23+
if (typeof default_icon === 'string') {
24+
const iconPath = path.join(extension.path, default_icon)
25+
return iconPath
26+
} else if (typeof default_icon === 'object') {
27+
const key = Object.keys(default_icon).pop() as any
28+
const iconPath = path.join(extension.path, default_icon[key])
29+
return iconPath
30+
}
31+
}
32+
33+
export const getIconImage = (extension: Electron.Extension) => {
34+
const iconPath = getIconPath(extension)
35+
return iconPath && nativeImage.createFromPath(iconPath)
36+
}

packages/electron-chrome-extensions/src/browser/api/context-menus.ts

+120-7
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,119 @@
1-
import { ipcMain } from 'electron'
1+
import { app, ipcMain, Menu, MenuItem } from 'electron'
22
import { EventEmitter } from 'events'
3+
import { MenuItemConstructorOptions } from 'electron/main'
4+
import { ExtensionAPIState } from '../api-state'
5+
import { getIconPath } from './common'
6+
7+
type ContextItemProps = chrome.contextMenus.CreateProperties
8+
9+
type ContextType =
10+
| 'all'
11+
| 'page'
12+
| 'frame'
13+
| 'selection'
14+
| 'link'
15+
| 'editable'
16+
| 'image'
17+
| 'video'
18+
| 'audio'
19+
| 'launcher'
20+
| 'browser_action'
21+
| 'page_action'
22+
| 'action'
23+
24+
const getContextTypesFromParams = (params: Electron.ContextMenuParams): Set<ContextType> => {
25+
const contexts = new Set<ContextType>(['all'])
26+
27+
switch (params.mediaType) {
28+
case 'audio':
29+
case 'video':
30+
case 'image':
31+
contexts.add(params.mediaType)
32+
}
33+
34+
if (params.pageURL) contexts.add('page')
35+
if (params.linkURL) contexts.add('link')
36+
if (params.frameURL) contexts.add('frame')
37+
if (params.selectionText) contexts.add('selection')
38+
if (params.isEditable) contexts.add('editable')
39+
40+
return contexts
41+
}
342

443
export class ContextMenusAPI extends EventEmitter {
5-
private menus = new Map</* extensionId */ string, any>()
44+
private menus = new Map<
45+
/* extensionId */ string,
46+
Map</* menuItemId */ string, ContextItemProps>
47+
>()
648

7-
constructor() {
49+
constructor(private state: ExtensionAPIState) {
850
super()
951

1052
ipcMain.handle('contextMenus.create', this.create)
53+
ipcMain.handle('contextMenus.remove', this.remove)
54+
ipcMain.handle('contextMenus.removeAll', this.removeAll)
55+
56+
this.state.session.on('extension-unloaded' as any, (event, extensionId) => {
57+
if (this.menus.has(extensionId)) {
58+
this.menus.delete(extensionId)
59+
}
60+
})
1161
}
1262

13-
private addContextItem(extensionId: string, item: any) {
63+
private addContextItem(extensionId: string, props: ContextItemProps) {
1464
let contextItems = this.menus.get(extensionId)
1565
if (!contextItems) {
16-
contextItems = []
66+
contextItems = new Map()
1767
this.menus.set(extensionId, contextItems)
1868
}
19-
contextItems.push(item)
69+
contextItems.set(props.id!, props)
70+
}
71+
72+
buildMenuItems(params: Electron.ContextMenuParams) {
73+
const buildMenuItem = (extension: Electron.Extension, props: ContextItemProps) => {
74+
const menuItemOptions: MenuItemConstructorOptions = {
75+
id: props.id,
76+
type: props.type as any,
77+
label: props.title,
78+
icon: getIconPath(extension),
79+
click: () => {
80+
// TODO
81+
this.onClicked({} as any, {})
82+
},
83+
}
84+
const menuItem = new MenuItem(menuItemOptions)
85+
return menuItem
86+
}
87+
88+
const menuItems = []
89+
const contextTypes = getContextTypesFromParams(params)
90+
91+
for (const [extensionId, propItems] of this.menus) {
92+
const extension = this.state.session.getExtension(extensionId)
93+
if (!extension) continue
94+
95+
for (const [, props] of propItems) {
96+
if (props.enabled === false) continue
97+
98+
if (props.contexts) {
99+
const inContext = props.contexts.some((context) =>
100+
contextTypes.has(context as ContextType)
101+
)
102+
if (!inContext) continue
103+
}
104+
105+
const menuItem = buildMenuItem(extension, props)
106+
menuItems.push(menuItem)
107+
}
108+
}
109+
110+
return menuItems
20111
}
21112

22113
private create = (
23114
event: Electron.IpcMainInvokeEvent,
24115
extensionId: string,
25-
createProperties: chrome.contextMenus.CreateProperties
116+
createProperties: ContextItemProps
26117
) => {
27118
const { id, type, title } = createProperties
28119

@@ -42,4 +133,26 @@ export class ContextMenusAPI extends EventEmitter {
42133
this.addContextItem(extensionId, createProperties)
43134
}
44135
}
136+
137+
private remove = (
138+
event: Electron.IpcMainInvokeEvent,
139+
extensionId: string,
140+
menuItemId: string
141+
) => {
142+
const items = this.menus.get(extensionId)
143+
if (items && items.has(menuItemId)) {
144+
items.delete(menuItemId)
145+
if (items.size === 0) {
146+
this.menus.delete(extensionId)
147+
}
148+
}
149+
}
150+
151+
private removeAll = (event: Electron.IpcMainInvokeEvent, extensionId: string) => {
152+
this.menus.delete(extensionId)
153+
}
154+
155+
private onClicked(info: chrome.contextMenus.OnClickData, tab: any) {
156+
this.state.sendToHosts('tabs.onCreated', info, tab)
157+
}
45158
}

packages/electron-chrome-extensions/src/browser/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ export class Extensions {
1313
state: ExtensionAPIState
1414

1515
browserAction = new BrowserActionAPI()
16-
contextMenus = new ContextMenusAPI()
16+
contextMenus: ContextMenusAPI
1717
tabs: TabsAPI
1818
webNavigation: WebNavigationAPI
1919
windows: WindowsAPI
2020

2121
constructor(session: Electron.Session) {
2222
this.state = new ExtensionAPIState(session)
2323

24+
this.contextMenus = new ContextMenusAPI(this.state)
2425
this.tabs = new TabsAPI(this.state)
2526
this.webNavigation = new WebNavigationAPI(this.state)
2627
this.windows = new WindowsAPI(this.state)
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export * from './browser'
1+
export * from './browser'
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { injectExtensionAPIs } from './renderer'
2-
3-
// Only load within extension page context
4-
if (location.href.startsWith('chrome-extension://')) {
5-
injectExtensionAPIs()
6-
}
1+
import { injectExtensionAPIs } from './renderer'
2+
3+
// Only load within extension page context
4+
if (location.href.startsWith('chrome-extension://')) {
5+
injectExtensionAPIs()
6+
}

packages/electron-chrome-extensions/src/renderer/index.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -174,25 +174,37 @@ export const injectExtensionAPIs = () => {
174174
})
175175

176176
let menuCounter = 0
177+
const menuCallbacks: { [key: string]: chrome.contextMenus.CreateProperties['onclick'] } = {}
177178
const menuCreate = invokeExtension('contextMenus.create', { extensionId })
178179

179180
const contextMenus: Partial<typeof chrome.contextMenus> = {
181+
...chrome.contextMenus,
180182
create: function (
181183
createProperties: chrome.contextMenus.CreateProperties,
182184
callback?: Function
183185
) {
184186
if (typeof createProperties.id === 'undefined') {
185187
createProperties.id = `${++menuCounter}`
186188
}
189+
if (createProperties.onclick) {
190+
menuCallbacks[createProperties.id] = createProperties.onclick
191+
delete createProperties.onclick
192+
}
187193
menuCreate(createProperties, callback)
188194
return createProperties.id
189195
},
190196
update: invokeExtension('contextMenus.update', { noop: true }),
191-
remove: invokeExtension('contextMenus.remove', { noop: true }),
192-
removeAll: invokeExtension('contextMenus.removeAll', { noop: true }),
197+
remove: invokeExtension('contextMenus.remove', { extensionId }),
198+
removeAll: invokeExtension('contextMenus.removeAll', { extensionId }),
193199
onClicked: new ExtensionEvent('contextMenus.onClicked'),
194200
}
195201

202+
contextMenus.onClicked?.addListener((info, tab) => {
203+
// TODO: test this
204+
const callback = menuCallbacks[info.menuItemId]
205+
if (callback && tab) callback(info, tab)
206+
})
207+
196208
const tabs: Partial<typeof chrome.tabs> = {
197209
...chrome.tabs,
198210
create: invokeExtension('tabs.create'),

packages/shell/browser/main.js

+64-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const path = require('path')
22
const { promises: fs } = require('fs')
3-
const { app, session, ipcMain, BrowserWindow, BrowserView } = require('electron')
3+
const { app, session, BrowserWindow, Menu, MenuItem, clipboard } = require('electron')
44

55
const { Tabs } = require('./tabs')
66
const { Extensions } = require('electron-chrome-extensions')
@@ -255,6 +255,65 @@ class Browser {
255255
return win
256256
}
257257

258+
createContextMenu(webContents, params) {
259+
const menu = new Menu()
260+
261+
const win = this.getFocusedWindow()
262+
263+
if (params.linkURL) {
264+
menu.append(new MenuItem({
265+
label: 'Open link in new tab',
266+
click: () => {
267+
const tab = win.tabs.create()
268+
tab.loadURL(params.linkURL)
269+
}
270+
}))
271+
menu.append(new MenuItem({
272+
label: 'Open link in new window',
273+
click: () => {
274+
this.createWindow({ initialUrl: params.linkURL })
275+
}
276+
}))
277+
} else if (params.selectionText) {
278+
menu.append(new MenuItem({
279+
label: 'Copy',
280+
click: () => {
281+
clipboard.writeText(params.selectionText)
282+
}
283+
}))
284+
} else {
285+
menu.append(new MenuItem({
286+
label: 'Back',
287+
enabled: webContents.canGoBack(),
288+
click: () => webContents.goBack()
289+
}))
290+
menu.append(new MenuItem({
291+
label: 'Forward',
292+
enabled: webContents.canGoForward(),
293+
click: () => webContents.goForward()
294+
}))
295+
menu.append(new MenuItem({
296+
label: 'Reload',
297+
click: () => webContents.reload()
298+
}))
299+
}
300+
301+
menu.append(new MenuItem({ type: 'separator' }))
302+
303+
const items = this.extensions.contextMenus.buildMenuItems(params)
304+
items.forEach((item) => menu.append(item))
305+
if (items.length > 0) menu.append(new MenuItem({ type: 'separator' }))
306+
307+
menu.append(new MenuItem({
308+
label: 'Inspect',
309+
click: () => {
310+
webContents.openDevTools()
311+
}
312+
}))
313+
314+
menu.popup()
315+
}
316+
258317
async onWebContentsCreated(event, webContents) {
259318
const type = webContents.getType()
260319
const url = webContents.getURL()
@@ -283,6 +342,10 @@ class Browser {
283342
break
284343
}
285344
})
345+
346+
webContents.on('context-menu', (event, params) => {
347+
this.createContextMenu(webContents, params)
348+
})
286349
}
287350
}
288351

0 commit comments

Comments
 (0)