aboutsummaryrefslogtreecommitdiffstats
path: root/src/webview
diff options
context:
space:
mode:
Diffstat (limited to 'src/webview')
-rw-r--r--src/webview/contextMenu.js355
-rw-r--r--src/webview/lib/RecipeWebview.js25
-rw-r--r--src/webview/recipe.js76
-rw-r--r--src/webview/spellchecker.js12
4 files changed, 125 insertions, 343 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 1import { remote } from 'electron';
2// @sindresorhus 2import { ContextMenuBuilder, ContextMenuListener } from 'electron-spellchecker';
3 3
4import {
5 clipboard, remote, ipcRenderer, shell,
6} from 'electron';
7
8import { isDevMode, isMac } from '../environment';
9import { SPELLCHECKER_LOCALES } from '../i18n/languages';
10
11const debug = require('debug')('Ferdi:contextMenu');
12
13const { Menu } = remote;
14
15// const win = remote.getCurrentWindow();
16const webContents = remote.getCurrentWebContents(); 4const webContents = remote.getCurrentWebContents();
17 5
18function delUnusedElements(menuTpl) { 6export 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(
27const 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
299export 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/lib/RecipeWebview.js b/src/webview/lib/RecipeWebview.js
index 74d05fc2d..4fac21c55 100644
--- a/src/webview/lib/RecipeWebview.js
+++ b/src/webview/lib/RecipeWebview.js
@@ -19,6 +19,8 @@ class RecipeWebview {
19 19
20 loopFunc = () => null; 20 loopFunc = () => null;
21 21
22 darkModeHandler = false;
23
22 /** 24 /**
23 * Initialize the loop 25 * Initialize the loop
24 * 26 *
@@ -67,17 +69,28 @@ class RecipeWebview {
67 * be an absolute path to the file 69 * be an absolute path to the file
68 */ 70 */
69 injectCSS(...files) { 71 injectCSS(...files) {
70 files.forEach((file) => { 72 files.forEach(async (file) => {
71 const data = fs.readFileSync(file); 73 if (await fs.exists(file)) {
72 const styles = document.createElement('style'); 74 const data = await fs.readFile(file);
73 styles.innerHTML = data.toString(); 75 const styles = document.createElement('style');
76 styles.innerHTML = data.toString();
74 77
75 document.querySelector('head').appendChild(styles); 78 document.querySelector('head').appendChild(styles);
76 79
77 debug('Append styles', styles); 80 debug('Append styles', styles);
81 }
78 }); 82 });
79 } 83 }
80 84
85 /**
86 * Set a custom handler for turning on and off dark mode
87 *
88 * @param {function} handler
89 */
90 handleDarkMode(handler) {
91 this.darkModeHandler = handler;
92 }
93
81 onNotify(fn) { 94 onNotify(fn) {
82 if (typeof fn === 'function') { 95 if (typeof fn === 'function') {
83 window.Notification.prototype.onNotify = fn; 96 window.Notification.prototype.onNotify = fn;
diff --git a/src/webview/recipe.js b/src/webview/recipe.js
index 1a22542d8..bad5a93b2 100644
--- a/src/webview/recipe.js
+++ b/src/webview/recipe.js
@@ -1,10 +1,11 @@
1/* eslint-disable import/first */ 1/* eslint-disable import/first */
2import { ipcRenderer } from 'electron'; 2import { ipcRenderer, remote } from 'electron';
3import path from 'path'; 3import path from 'path';
4import { autorun, computed, observable } from 'mobx'; 4import { autorun, computed, observable } from 'mobx';
5import fs from 'fs-extra'; 5import fs from 'fs-extra';
6import { loadModule } from 'cld3-asm'; 6import { loadModule } from 'cld3-asm';
7import { debounce } from 'lodash'; 7import { debounce } from 'lodash';
8import { FindInPage } from 'electron-find';
8 9
9// For some services darkreader tries to use the chrome extension message API 10// For some services darkreader tries to use the chrome extension message API
10// This will cause the service to fail loading 11// This will cause the service to fail loading
@@ -23,7 +24,6 @@ import RecipeWebview from './lib/RecipeWebview';
23 24
24import spellchecker, { switchDict, disable as disableSpellchecker, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker'; 25import spellchecker, { switchDict, disable as disableSpellchecker, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker';
25import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode'; 26import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode';
26import contextMenu from './contextMenu';
27import './notifications'; 27import './notifications';
28 28
29import { DEFAULT_APP_SETTINGS } from '../config'; 29import { DEFAULT_APP_SETTINGS } from '../config';
@@ -48,10 +48,15 @@ class RecipeController {
48 'settings-update': 'updateAppSettings', 48 'settings-update': 'updateAppSettings',
49 'service-settings-update': 'updateServiceSettings', 49 'service-settings-update': 'updateServiceSettings',
50 'get-service-id': 'serviceIdEcho', 50 'get-service-id': 'serviceIdEcho',
51 'find-in-page': 'openFindInPage',
51 }; 52 };
52 53
53 universalDarkModeInjected = false; 54 universalDarkModeInjected = false;
54 55
56 recipe = null;
57
58 hasUpdatedBeforeRecipeLoaded = false;
59
55 constructor() { 60 constructor() {
56 this.initialize(); 61 this.initialize();
57 } 62 }
@@ -62,6 +67,8 @@ class RecipeController {
62 67
63 cldIdentifier = null; 68 cldIdentifier = null;
64 69
70 findInPage = null;
71
65 async initialize() { 72 async initialize() {
66 Object.keys(this.ipcEvents).forEach((channel) => { 73 Object.keys(this.ipcEvents).forEach((channel) => {
67 ipcRenderer.on(channel, (...args) => { 74 ipcRenderer.on(channel, (...args) => {
@@ -72,16 +79,15 @@ class RecipeController {
72 79
73 debug('Send "hello" to host'); 80 debug('Send "hello" to host');
74 setTimeout(() => ipcRenderer.sendToHost('hello'), 100); 81 setTimeout(() => ipcRenderer.sendToHost('hello'), 100);
75 82 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()); 83 autorun(() => this.update());
84
85 document.addEventListener('DOMContentLoaded', () => {
86 this.findInPage = new FindInPage(remote.getCurrentWebContents(), {
87 inputFocusColor: '#CE9FFC',
88 textColor: '#212121',
89 });
90 });
85 } 91 }
86 92
87 loadRecipeModule(event, config, recipe) { 93 loadRecipeModule(event, config, recipe) {
@@ -91,11 +97,15 @@ class RecipeController {
91 // Delete module from cache 97 // Delete module from cache
92 delete require.cache[require.resolve(modulePath)]; 98 delete require.cache[require.resolve(modulePath)];
93 try { 99 try {
100 this.recipe = new RecipeWebview();
94 // eslint-disable-next-line 101 // eslint-disable-next-line
95 require(modulePath)(new RecipeWebview(), {...config, recipe,}); 102 require(modulePath)(this.recipe, {...config, recipe,});
96 debug('Initialize Recipe', config, recipe); 103 debug('Initialize Recipe', config, recipe);
97 104
98 this.settings.service = Object.assign(config, { recipe }); 105 this.settings.service = Object.assign(config, { recipe });
106
107 // Make sure to update the WebView, otherwise the custom darkmode handler may not be used
108 this.update();
99 } catch (err) { 109 } catch (err) {
100 console.error('Recipe initialization failed', err); 110 console.error('Recipe initialization failed', err);
101 } 111 }
@@ -134,6 +144,10 @@ class RecipeController {
134 } 144 }
135 } 145 }
136 146
147 openFindInPage() {
148 this.findInPage.openFindWindow();
149 }
150
137 update() { 151 update() {
138 debug('enableSpellchecking', this.settings.app.enableSpellchecking); 152 debug('enableSpellchecking', this.settings.app.enableSpellchecking);
139 debug('isDarkModeEnabled', this.settings.service.isDarkModeEnabled); 153 debug('isDarkModeEnabled', this.settings.service.isDarkModeEnabled);
@@ -160,12 +174,25 @@ class RecipeController {
160 } 174 }
161 } 175 }
162 176
177 if (!this.recipe) {
178 this.hasUpdatedBeforeRecipeLoaded = true;
179 }
180
163 console.log( 181 console.log(
164 'Darkmode enabled?', 182 'Darkmode enabled?',
165 this.settings.service.isDarkModeEnabled, 183 this.settings.service.isDarkModeEnabled,
166 'Dark theme active?', 184 'Dark theme active?',
167 this.settings.app.isDarkThemeActive, 185 this.settings.app.isDarkThemeActive,
168 ); 186 );
187
188 const handlerConfig = {
189 removeDarkModeStyle,
190 disableDarkMode,
191 enableDarkMode,
192 injectDarkModeStyle: () => injectDarkModeStyle(this.settings.service.recipe.path),
193 isDarkModeStyleInjected,
194 };
195
169 if (this.settings.service.isDarkModeEnabled && this.settings.app.isDarkThemeActive !== false) { 196 if (this.settings.service.isDarkModeEnabled && this.settings.app.isDarkThemeActive !== false) {
170 debug('Enable dark mode'); 197 debug('Enable dark mode');
171 198
@@ -175,7 +202,19 @@ class RecipeController {
175 202
176 console.log('darkmode.css exists? ', darkModeExists ? 'Yes' : 'No'); 203 console.log('darkmode.css exists? ', darkModeExists ? 'Yes' : 'No');
177 204
178 if (darkModeExists) { 205 // Check if recipe has a custom dark mode handler
206 if (this.recipe && this.recipe.darkModeHandler) {
207 console.log('Using custom dark mode handler');
208
209 // Remove other dark mode styles if they were already loaded
210 if (this.hasUpdatedBeforeRecipeLoaded) {
211 this.hasUpdatedBeforeRecipeLoaded = false;
212 removeDarkModeStyle();
213 disableDarkMode();
214 }
215
216 this.recipe.darkModeHandler(true, handlerConfig);
217 } else if (darkModeExists) {
179 console.log('Injecting darkmode.css'); 218 console.log('Injecting darkmode.css');
180 injectDarkModeStyle(this.settings.service.recipe.path); 219 injectDarkModeStyle(this.settings.service.recipe.path);
181 220
@@ -195,7 +234,16 @@ class RecipeController {
195 debug('Remove dark mode'); 234 debug('Remove dark mode');
196 console.log('DarkMode disabled - removing remaining styles'); 235 console.log('DarkMode disabled - removing remaining styles');
197 236
198 if (isDarkModeStyleInjected()) { 237 if (this.recipe && this.recipe.darkModeHandler) {
238 // Remove other dark mode styles if they were already loaded
239 if (this.hasUpdatedBeforeRecipeLoaded) {
240 this.hasUpdatedBeforeRecipeLoaded = false;
241 removeDarkModeStyle();
242 disableDarkMode();
243 }
244
245 this.recipe.darkModeHandler(false, handlerConfig);
246 } else if (isDarkModeStyleInjected()) {
199 console.log('Removing injected darkmode.css'); 247 console.log('Removing injected darkmode.css');
200 removeDarkModeStyle(); 248 removeDarkModeStyle();
201 } else { 249 } else {
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 @@
1import { webFrame } from 'electron'; 1import { webFrame } from 'electron';
2import { SpellCheckHandler, ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker'; 2import { SpellCheckHandler } from 'electron-spellchecker';
3
4import { SPELLCHECKER_LOCALES } from '../i18n/languages'; 3import { SPELLCHECKER_LOCALES } from '../i18n/languages';
4import setupContextMenu from './contextMenu';
5 5
6const debug = require('debug')('Franz:spellchecker'); 6const debug = require('debug')('Franz:spellchecker');
7 7
8let handler; 8let handler;
9let currentDict; 9let currentDict;
10let contextMenuBuilder;
11let _isEnabled = false; 10let _isEnabled = false;
12 11
13export async function switchDict(locale) { 12export 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) {