diff options
author | Amine <amine@mouafik.fr> | 2020-03-02 13:57:49 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-03-02 13:57:49 +0100 |
commit | 7cc26c9b84191e9ecff0866a7b5d5248bb4207b1 (patch) | |
tree | d18858559d8aad6c881c65e7b309d19686cba2db | |
parent | New Crowdin translations (#420) (diff) | |
parent | Run linter (diff) | |
download | ferdium-app-7cc26c9b84191e9ecff0866a7b5d5248bb4207b1.tar.gz ferdium-app-7cc26c9b84191e9ecff0866a7b5d5248bb4207b1.tar.zst ferdium-app-7cc26c9b84191e9ecff0866a7b5d5248bb4207b1.zip |
Merge pull request #419 from getferdi/fix/context-menu
Fix and enhance context menu
-rw-r--r-- | src/lib/Menu.js | 2 | ||||
-rw-r--r-- | src/stores/SettingsStore.js | 7 | ||||
-rw-r--r-- | src/webview/contextMenu.js | 355 | ||||
-rw-r--r-- | src/webview/recipe.js | 11 | ||||
-rw-r--r-- | src/webview/spellchecker.js | 12 |
5 files changed, 51 insertions, 336 deletions
diff --git a/src/lib/Menu.js b/src/lib/Menu.js index 1c4cc6ab5..6d5eb0095 100644 --- a/src/lib/Menu.js +++ b/src/lib/Menu.js | |||
@@ -819,7 +819,7 @@ export default class FranzMenu { | |||
819 | locked: true, | 819 | locked: true, |
820 | }, | 820 | }, |
821 | }); | 821 | }); |
822 | } | 822 | }, |
823 | }); | 823 | }); |
824 | 824 | ||
825 | if (serviceTpl.length > 0) { | 825 | if (serviceTpl.length > 0) { |
diff --git a/src/stores/SettingsStore.js b/src/stores/SettingsStore.js index 26e83b725..71d4e1702 100644 --- a/src/stores/SettingsStore.js +++ b/src/stores/SettingsStore.js | |||
@@ -1,5 +1,7 @@ | |||
1 | import { ipcRenderer, remote } from 'electron'; | 1 | import { ipcRenderer, remote } from 'electron'; |
2 | import { action, computed, observable, reaction } from 'mobx'; | 2 | import { |
3 | action, computed, observable, reaction, | ||
4 | } from 'mobx'; | ||
3 | import localStorage from 'mobx-localstorage'; | 5 | import localStorage from 'mobx-localstorage'; |
4 | import { DEFAULT_APP_SETTINGS, FILE_SYSTEM_SETTINGS_TYPES, LOCAL_SERVER } from '../config'; | 6 | import { DEFAULT_APP_SETTINGS, FILE_SYSTEM_SETTINGS_TYPES, LOCAL_SERVER } from '../config'; |
5 | import { API } from '../environment'; | 7 | import { API } from '../environment'; |
@@ -12,6 +14,7 @@ const debug = require('debug')('Ferdi:SettingsStore'); | |||
12 | 14 | ||
13 | export default class SettingsStore extends Store { | 15 | export default class SettingsStore extends Store { |
14 | @observable updateAppSettingsRequest = new Request(this.api.local, 'updateAppSettings'); | 16 | @observable updateAppSettingsRequest = new Request(this.api.local, 'updateAppSettings'); |
17 | |||
15 | startup = true; | 18 | startup = true; |
16 | 19 | ||
17 | fileSystemSettingsTypes = FILE_SYSTEM_SETTINGS_TYPES; | 20 | fileSystemSettingsTypes = FILE_SYSTEM_SETTINGS_TYPES; |
@@ -103,7 +106,7 @@ export default class SettingsStore extends Store { | |||
103 | // So we lock manually | 106 | // So we lock manually |
104 | window.ferdi.stores.router.push('/auth/locked'); | 107 | window.ferdi.stores.router.push('/auth/locked'); |
105 | } | 108 | } |
106 | }) | 109 | }); |
107 | } | 110 | } |
108 | debug('Get appSettings resolves', resp.type, resp.data); | 111 | debug('Get appSettings resolves', resp.type, resp.data); |
109 | Object.assign(this._fileSystemSettingsCache[resp.type], resp.data); | 112 | Object.assign(this._fileSystemSettingsCache[resp.type], resp.data); |
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 | } |
diff --git a/src/webview/recipe.js b/src/webview/recipe.js index 1a22542d8..07d29f477 100644 --- a/src/webview/recipe.js +++ b/src/webview/recipe.js | |||
@@ -23,7 +23,6 @@ import RecipeWebview from './lib/RecipeWebview'; | |||
23 | 23 | ||
24 | import spellchecker, { switchDict, disable as disableSpellchecker, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker'; | 24 | import spellchecker, { switchDict, disable as disableSpellchecker, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker'; |
25 | import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode'; | 25 | import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode'; |
26 | import contextMenu from './contextMenu'; | ||
27 | import './notifications'; | 26 | import './notifications'; |
28 | 27 | ||
29 | import { DEFAULT_APP_SETTINGS } from '../config'; | 28 | import { DEFAULT_APP_SETTINGS } from '../config'; |
@@ -72,15 +71,7 @@ class RecipeController { | |||
72 | 71 | ||
73 | debug('Send "hello" to host'); | 72 | debug('Send "hello" to host'); |
74 | setTimeout(() => ipcRenderer.sendToHost('hello'), 100); | 73 | setTimeout(() => ipcRenderer.sendToHost('hello'), 100); |
75 | 74 | await spellchecker(); | |
76 | this.spellcheckingProvider = await spellchecker(); | ||
77 | contextMenu( | ||
78 | this.spellcheckingProvider, | ||
79 | () => this.settings.app.enableSpellchecking, | ||
80 | () => this.settings.app.spellcheckerLanguage, | ||
81 | () => this.spellcheckerLanguage, | ||
82 | ); | ||
83 | |||
84 | autorun(() => this.update()); | 75 | autorun(() => this.update()); |
85 | } | 76 | } |
86 | 77 | ||
diff --git a/src/webview/spellchecker.js b/src/webview/spellchecker.js index 8a1c8782b..a33a506b2 100644 --- a/src/webview/spellchecker.js +++ b/src/webview/spellchecker.js | |||
@@ -1,13 +1,12 @@ | |||
1 | import { webFrame } from 'electron'; | 1 | import { webFrame } from 'electron'; |
2 | import { SpellCheckHandler, ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker'; | 2 | import { SpellCheckHandler } from 'electron-spellchecker'; |
3 | |||
4 | import { SPELLCHECKER_LOCALES } from '../i18n/languages'; | 3 | import { SPELLCHECKER_LOCALES } from '../i18n/languages'; |
4 | import setupContextMenu from './contextMenu'; | ||
5 | 5 | ||
6 | const debug = require('debug')('Franz:spellchecker'); | 6 | const debug = require('debug')('Franz:spellchecker'); |
7 | 7 | ||
8 | let handler; | 8 | let handler; |
9 | let currentDict; | 9 | let currentDict; |
10 | let contextMenuBuilder; | ||
11 | let _isEnabled = false; | 10 | let _isEnabled = false; |
12 | 11 | ||
13 | export async function switchDict(locale) { | 12 | export async function switchDict(locale) { |
@@ -46,12 +45,7 @@ export default async function initialize(languageCode = 'en-us') { | |||
46 | debug('Init spellchecker'); | 45 | debug('Init spellchecker'); |
47 | 46 | ||
48 | switchDict(locale); | 47 | switchDict(locale); |
49 | 48 | setupContextMenu(handler); | |
50 | contextMenuBuilder = new ContextMenuBuilder(handler); | ||
51 | // eslint-disable-next-line no-new | ||
52 | new ContextMenuListener((info) => { | ||
53 | contextMenuBuilder.showPopupMenu(info); | ||
54 | }); | ||
55 | 49 | ||
56 | return handler; | 50 | return handler; |
57 | } catch (err) { | 51 | } catch (err) { |