diff options
Diffstat (limited to 'src/webview/contextMenu.js')
-rw-r--r-- | src/webview/contextMenu.js | 355 |
1 files changed, 41 insertions, 314 deletions
diff --git a/src/webview/contextMenu.js b/src/webview/contextMenu.js index acd62d675..eeb825ece 100644 --- a/src/webview/contextMenu.js +++ b/src/webview/contextMenu.js | |||
@@ -1,323 +1,50 @@ | |||
1 | // This is heavily based on https://github.com/sindresorhus/electron-context-menu | 1 | import { remote } from 'electron'; |
2 | // ❤ @sindresorhus | 2 | import { ContextMenuBuilder, ContextMenuListener } from 'electron-spellchecker'; |
3 | 3 | ||
4 | import { | ||
5 | clipboard, remote, ipcRenderer, shell, | ||
6 | } from 'electron'; | ||
7 | |||
8 | import { isDevMode, isMac } from '../environment'; | ||
9 | import { SPELLCHECKER_LOCALES } from '../i18n/languages'; | ||
10 | |||
11 | const debug = require('debug')('Ferdi:contextMenu'); | ||
12 | |||
13 | const { Menu } = remote; | ||
14 | |||
15 | // const win = remote.getCurrentWindow(); | ||
16 | const webContents = remote.getCurrentWebContents(); | 4 | const webContents = remote.getCurrentWebContents(); |
17 | 5 | ||
18 | function delUnusedElements(menuTpl) { | 6 | export default async function setupContextMenu(handler) { |
19 | let notDeletedPrevEl; | 7 | const addCustomMenuItems = (menu, menuInfo) => { |
20 | return menuTpl.filter(el => el.visible !== false).filter((el, i, array) => { | 8 | // Add "Paste as plain text" item when right-clicking editable content |
21 | const toDelete = el.type === 'separator' && (!notDeletedPrevEl || i === array.length - 1 || array[i + 1].type === 'separator'); | 9 | if ( |
22 | notDeletedPrevEl = toDelete ? notDeletedPrevEl : el; | 10 | menuInfo.editFlags.canPaste |
23 | return !toDelete; | 11 | && !menuInfo.linkText |
24 | }); | 12 | && !menuInfo.hasImageContents |
25 | } | 13 | ) { |
26 | 14 | menu.insert( | |
27 | const buildMenuTpl = (props, suggestions, isSpellcheckEnabled, defaultSpellcheckerLanguage, spellcheckerLanguage) => { | 15 | 3, |
28 | const { editFlags } = props; | 16 | new remote.MenuItem({ |
29 | const textSelection = props.selectionText.trim(); | 17 | label: 'Paste as plain text', |
30 | const hasText = textSelection.length > 0; | 18 | accelerator: 'CommandOrControl+Shift+V', |
31 | const can = type => editFlags[`can${type}`] && hasText; | 19 | click: () => webContents.pasteAndMatchStyle(), |
32 | 20 | }), | |
33 | const canGoBack = webContents.canGoBack(); | 21 | ); |
34 | const canGoForward = webContents.canGoForward(); | 22 | } |
35 | 23 | ||
36 | // @adlk: we can't use roles here due to a bug with electron where electron.remote.webContents.getFocusedWebContents() returns the first webview in DOM instead of the focused one | 24 | // Add "Open Link in Ferdi" item for links |
37 | // Github issue creation is pending | 25 | if (menuInfo.linkURL) { |
38 | let menuTpl = [ | 26 | menu.insert( |
39 | { | 27 | 2, |
40 | type: 'separator', | 28 | new remote.MenuItem({ |
41 | }, { | 29 | label: 'Open Link in Ferdi', |
42 | id: 'createTodo', | 30 | click: () => { |
43 | label: `Create todo: "${textSelection.length > 15 ? `${textSelection.slice(0, 15)}...` : textSelection}"`, | 31 | window.location.href = menuInfo.linkURL; |
44 | visible: hasText, | ||
45 | click() { | ||
46 | debug('Create todo from selected text', textSelection); | ||
47 | ipcRenderer.sendToHost('feature:todos', { | ||
48 | action: 'todos:create', | ||
49 | data: { | ||
50 | title: textSelection, | ||
51 | url: window.location.href, | ||
52 | }, | 32 | }, |
53 | }); | 33 | }), |
54 | }, | 34 | ); |
55 | }, | ||
56 | { | ||
57 | type: 'separator', | ||
58 | }, { | ||
59 | id: 'lookup', | ||
60 | label: `Look Up "${textSelection.length > 15 ? `${textSelection.slice(0, 15)}...` : textSelection}"`, | ||
61 | visible: isMac && props.mediaType === 'none' && hasText, | ||
62 | click() { | ||
63 | debug('Show definition for selection', textSelection); | ||
64 | webContents.showDefinitionForSelection(); | ||
65 | }, | ||
66 | }, { | ||
67 | type: 'separator', | ||
68 | }, { | ||
69 | id: 'cut', | ||
70 | label: 'Cut', | ||
71 | click() { | ||
72 | if (can('Cut')) { | ||
73 | webContents.cut(); | ||
74 | } | ||
75 | }, | ||
76 | enabled: can('Cut'), | ||
77 | visible: hasText && props.isEditable, | ||
78 | }, { | ||
79 | id: 'copy', | ||
80 | label: 'Copy', | ||
81 | click() { | ||
82 | if (can('Copy')) { | ||
83 | webContents.copy(); | ||
84 | } | ||
85 | }, | ||
86 | enabled: can('Copy'), | ||
87 | visible: props.isEditable || hasText, | ||
88 | }, { | ||
89 | id: 'paste', | ||
90 | label: 'Paste', | ||
91 | click() { | ||
92 | if (editFlags.canPaste) { | ||
93 | webContents.paste(); | ||
94 | } | ||
95 | }, | ||
96 | enabled: editFlags.canPaste, | ||
97 | visible: props.isEditable, | ||
98 | }, { | ||
99 | type: 'separator', | ||
100 | visible: props.isEditable && hasText, | ||
101 | }, { | ||
102 | id: 'searchTextSelection', | ||
103 | label: `Search Google for "${textSelection.length > 15 ? `${textSelection.slice(0, 15)}...` : textSelection}"`, | ||
104 | visible: hasText, | ||
105 | click() { | ||
106 | const url = `https://www.google.com/search?q=${textSelection}`; | ||
107 | debug('Search on Google', url); | ||
108 | shell.openExternal(url); | ||
109 | }, | ||
110 | }, { | ||
111 | type: 'separator', | ||
112 | }, | ||
113 | ]; | ||
114 | |||
115 | if (props.linkURL && props.mediaType === 'none') { | ||
116 | menuTpl = [{ | ||
117 | type: 'separator', | ||
118 | }, { | ||
119 | id: 'openLink', | ||
120 | label: 'Open Link in Browser', | ||
121 | click() { | ||
122 | debug('Open link in Browser', props.linkURL); | ||
123 | shell.openExternal(props.linkURL); | ||
124 | }, | ||
125 | }, { | ||
126 | id: 'copyLink', | ||
127 | label: 'Copy Link', | ||
128 | click() { | ||
129 | clipboard.write({ | ||
130 | bookmark: props.linkText, | ||
131 | text: props.linkURL, | ||
132 | }); | ||
133 | }, | ||
134 | }, { | ||
135 | type: 'separator', | ||
136 | }]; | ||
137 | } | ||
138 | |||
139 | if (props.mediaType === 'image') { | ||
140 | menuTpl.push({ | ||
141 | type: 'separator', | ||
142 | }, { | ||
143 | id: 'openImage', | ||
144 | label: 'Open Image in Browser', | ||
145 | click() { | ||
146 | debug('Open image in Browser', props.srcURL); | ||
147 | shell.openExternal(props.srcURL); | ||
148 | }, | ||
149 | }, { | ||
150 | id: 'copyImageAddress', | ||
151 | label: 'Copy Image Address', | ||
152 | click() { | ||
153 | clipboard.write({ | ||
154 | bookmark: props.srcURL, | ||
155 | text: props.srcURL, | ||
156 | }); | ||
157 | }, | ||
158 | }, { | ||
159 | type: 'separator', | ||
160 | }); | ||
161 | } | ||
162 | |||
163 | if (props.mediaType === 'image') { | ||
164 | menuTpl.push({ | ||
165 | id: 'saveImageAs', | ||
166 | label: 'Save Image As…', | ||
167 | async click() { | ||
168 | if (props.srcURL.startsWith('blob:')) { | ||
169 | const url = new window.URL(props.srcURL.substr(5)); | ||
170 | const fileName = url.pathname.substr(1); | ||
171 | const resp = await window.fetch(props.srcURL); | ||
172 | const blob = await resp.blob(); | ||
173 | const reader = new window.FileReader(); | ||
174 | reader.readAsDataURL(blob); | ||
175 | reader.onloadend = () => { | ||
176 | const base64data = reader.result; | ||
177 | |||
178 | ipcRenderer.send('download-file', { | ||
179 | content: base64data, | ||
180 | fileOptions: { | ||
181 | name: fileName, | ||
182 | mime: blob.type, | ||
183 | }, | ||
184 | }); | ||
185 | }; | ||
186 | debug('binary string', blob); | ||
187 | } else { | ||
188 | ipcRenderer.send('download-file', { url: props.srcURL }); | ||
189 | } | ||
190 | }, | ||
191 | }, { | ||
192 | type: 'separator', | ||
193 | }); | ||
194 | } | ||
195 | |||
196 | if (suggestions.length > 0) { | ||
197 | suggestions.reverse().map(suggestion => menuTpl.unshift({ | ||
198 | id: `suggestion-${suggestion}`, | ||
199 | label: suggestion, | ||
200 | click() { | ||
201 | webContents.replaceMisspelling(suggestion); | ||
202 | }, | ||
203 | })); | ||
204 | } | ||
205 | |||
206 | if (canGoBack || canGoForward) { | ||
207 | menuTpl.push({ | ||
208 | type: 'separator', | ||
209 | }, { | ||
210 | id: 'goBack', | ||
211 | label: 'Go Back', | ||
212 | enabled: canGoBack, | ||
213 | click() { | ||
214 | webContents.goBack(); | ||
215 | }, | ||
216 | }, { | ||
217 | id: 'goForward', | ||
218 | label: 'Go Forward', | ||
219 | enabled: canGoForward, | ||
220 | click() { | ||
221 | webContents.goForward(); | ||
222 | }, | ||
223 | }, { | ||
224 | type: 'separator', | ||
225 | }); | ||
226 | } | ||
227 | |||
228 | const spellcheckingLanguages = []; | ||
229 | Object.keys(SPELLCHECKER_LOCALES).sort(Intl.Collator().compare).forEach((key) => { | ||
230 | spellcheckingLanguages.push({ | ||
231 | id: `lang-${key}`, | ||
232 | label: SPELLCHECKER_LOCALES[key], | ||
233 | type: 'radio', | ||
234 | checked: spellcheckerLanguage === key, | ||
235 | click() { | ||
236 | debug('Setting service spellchecker to', key); | ||
237 | ipcRenderer.sendToHost('set-service-spellchecker-language', key); | ||
238 | }, | ||
239 | }); | ||
240 | }); | ||
241 | |||
242 | menuTpl.push({ | ||
243 | type: 'separator', | ||
244 | }, { | ||
245 | id: 'spellchecker', | ||
246 | label: 'Spell Checking', | ||
247 | visible: isSpellcheckEnabled, | ||
248 | submenu: [ | ||
249 | { | ||
250 | id: 'spellchecker', | ||
251 | label: 'Available Languages', | ||
252 | enabled: false, | ||
253 | }, { | ||
254 | type: 'separator', | ||
255 | }, | ||
256 | { | ||
257 | id: 'resetToDefault', | ||
258 | label: `Reset to system default (${defaultSpellcheckerLanguage === 'automatic' ? 'Automatic' : SPELLCHECKER_LOCALES[defaultSpellcheckerLanguage]})`, | ||
259 | type: 'radio', | ||
260 | visible: defaultSpellcheckerLanguage !== spellcheckerLanguage || (defaultSpellcheckerLanguage !== 'automatic' && spellcheckerLanguage === 'automatic'), | ||
261 | click() { | ||
262 | debug('Resetting service spellchecker to system default'); | ||
263 | ipcRenderer.sendToHost('set-service-spellchecker-language', 'reset'); | ||
264 | }, | ||
265 | }, | ||
266 | { | ||
267 | id: 'automaticDetection', | ||
268 | label: 'Automatic language detection', | ||
269 | type: 'radio', | ||
270 | checked: spellcheckerLanguage === 'automatic', | ||
271 | click() { | ||
272 | debug('Detect language automatically'); | ||
273 | ipcRenderer.sendToHost('set-service-spellchecker-language', 'automatic'); | ||
274 | }, | ||
275 | }, | ||
276 | { | ||
277 | type: 'separator', | ||
278 | visible: defaultSpellcheckerLanguage !== spellcheckerLanguage, | ||
279 | }, | ||
280 | ...spellcheckingLanguages], | ||
281 | }); | ||
282 | |||
283 | |||
284 | if (isDevMode) { | ||
285 | menuTpl.push({ | ||
286 | type: 'separator', | ||
287 | }, { | ||
288 | id: 'inspect', | ||
289 | label: 'Inspect Element', | ||
290 | click() { | ||
291 | webContents.inspectElement(props.x, props.y); | ||
292 | }, | ||
293 | }); | ||
294 | } | ||
295 | |||
296 | return delUnusedElements(menuTpl); | ||
297 | }; | ||
298 | |||
299 | export default function contextMenu(spellcheckProvider, isSpellcheckEnabled, getDefaultSpellcheckerLanguage, getSpellcheckerLanguage) { | ||
300 | webContents.on('context-menu', async (e, props) => { | ||
301 | e.preventDefault(); | ||
302 | |||
303 | let suggestions = []; | ||
304 | if (spellcheckProvider && props.misspelledWord) { | ||
305 | debug('Mispelled word', props.misspelledWord); | ||
306 | suggestions = await spellcheckProvider.getSuggestion(props.misspelledWord); | ||
307 | |||
308 | debug('Suggestions', suggestions); | ||
309 | } | 35 | } |
310 | 36 | ||
311 | const menu = Menu.buildFromTemplate( | 37 | return menu; |
312 | buildMenuTpl( | 38 | }; |
313 | props, | 39 | |
314 | suggestions.slice(0, 5), | 40 | const contextMenuBuilder = new ContextMenuBuilder( |
315 | isSpellcheckEnabled(), | 41 | handler, |
316 | getDefaultSpellcheckerLanguage(), | 42 | null, |
317 | getSpellcheckerLanguage(), | 43 | true, |
318 | ), | 44 | addCustomMenuItems, |
319 | ); | 45 | ); |
320 | 46 | // eslint-disable-next-line no-new | |
321 | menu.popup(); | 47 | new ContextMenuListener((info) => { |
48 | contextMenuBuilder.showPopupMenu(info); | ||
322 | }); | 49 | }); |
323 | } | 50 | } |