aboutsummaryrefslogtreecommitdiffstats
path: root/src/webview
diff options
context:
space:
mode:
authorLibravatar Stefan Malzner <stefan@adlk.io>2018-12-02 15:08:07 +0100
committerLibravatar Stefan Malzner <stefan@adlk.io>2018-12-02 15:08:07 +0100
commita5aa5e1380a0847eb33be3315d164fb9e0e23255 (patch)
treea1ea4b0f635cabb54d9cf028809d2f079727b9a1 /src/webview
parentAdd instruction to install Franz with homebrew (#1134) (diff)
parentMerge branch 'develop' into release/5.0.0-beta.19 (diff)
downloadferdium-app-a5aa5e1380a0847eb33be3315d164fb9e0e23255.tar.gz
ferdium-app-a5aa5e1380a0847eb33be3315d164fb9e0e23255.tar.zst
ferdium-app-a5aa5e1380a0847eb33be3315d164fb9e0e23255.zip
Merge branch 'release/5.0.0-beta.19'
Diffstat (limited to 'src/webview')
-rw-r--r--src/webview/contextMenu.js178
-rw-r--r--src/webview/darkmode.js28
-rw-r--r--src/webview/plugin.js64
-rw-r--r--src/webview/spellchecker.js111
4 files changed, 325 insertions, 56 deletions
diff --git a/src/webview/contextMenu.js b/src/webview/contextMenu.js
new file mode 100644
index 000000000..195306fda
--- /dev/null
+++ b/src/webview/contextMenu.js
@@ -0,0 +1,178 @@
1// This is heavily based on https://github.com/sindresorhus/electron-context-menu
2// ❤ @sindresorhus
3
4import { clipboard, remote, ipcRenderer, shell } from 'electron';
5
6import { isDevMode } from '../environment';
7
8const debug = require('debug')('Franz:contextMenu');
9
10const { Menu } = remote;
11
12// const win = remote.getCurrentWindow();
13const webContents = remote.getCurrentWebContents();
14
15function delUnusedElements(menuTpl) {
16 let notDeletedPrevEl;
17 return menuTpl.filter(el => el.visible !== false).filter((el, i, array) => {
18 const toDelete = el.type === 'separator' && (!notDeletedPrevEl || i === array.length - 1 || array[i + 1].type === 'separator');
19 notDeletedPrevEl = toDelete ? notDeletedPrevEl : el;
20 return !toDelete;
21 });
22}
23
24const buildMenuTpl = (props, suggestions) => {
25 const { editFlags } = props;
26 const hasText = props.selectionText.trim().length > 0;
27 const can = type => editFlags[`can${type}`] && hasText;
28
29 console.log(props);
30
31 let menuTpl = [
32 {
33 type: 'separator',
34 }, {
35 id: 'cut',
36 role: can('Cut') ? 'cut' : '',
37 enabled: can('Cut'),
38 visible: !!props.selectionText.trim(),
39 }, {
40 id: 'copy',
41 label: 'Copy',
42 role: can('Copy') ? 'copy' : '',
43 enabled: can('Copy'),
44 visible: props.isEditable || hasText,
45 }, {
46 id: 'paste',
47 label: 'Paste',
48 role: editFlags.canPaste ? 'paste' : '',
49 enabled: editFlags.canPaste,
50 visible: props.isEditable,
51 }, {
52 type: 'separator',
53 },
54 ];
55
56 if (props.linkURL && props.mediaType === 'none') {
57 menuTpl = [{
58 type: 'separator',
59 }, {
60 id: 'openLink',
61 label: 'Open Link in Browser',
62 click() {
63 shell.openExternal(props.linkURL);
64 },
65 }, {
66 id: 'copyLink',
67 label: 'Copy Link',
68 click() {
69 clipboard.write({
70 bookmark: props.linkText,
71 text: props.linkURL,
72 });
73 },
74 }, {
75 type: 'separator',
76 }];
77 }
78
79 if (props.mediaType === 'image') {
80 menuTpl.push({
81 type: 'separator',
82 }, {
83 id: 'openImage',
84 label: 'Open Image in Browser',
85 click() {
86 shell.openExternal(props.srcURL);
87 },
88 }, {
89 id: 'copyImageAddress',
90 label: 'Copy Image Address',
91 click() {
92 clipboard.write({
93 bookmark: props.srcURL,
94 text: props.srcURL,
95 });
96 },
97 }, {
98 type: 'separator',
99 });
100 }
101
102 if (props.mediaType === 'image') {
103 menuTpl.push({
104 id: 'saveImageAs',
105 label: 'Save Image As…',
106 async click() {
107 if (props.srcURL.startsWith('blob:')) {
108 const url = new window.URL(props.srcURL.substr(5));
109 const fileName = url.pathname.substr(1);
110 const resp = await window.fetch(props.srcURL);
111 const blob = await resp.blob();
112 const reader = new window.FileReader();
113 reader.readAsDataURL(blob);
114 reader.onloadend = () => {
115 const base64data = reader.result;
116
117 ipcRenderer.send('download-file', {
118 content: base64data,
119 fileOptions: {
120 name: fileName,
121 mime: blob.type,
122 },
123 });
124 };
125 debug('binary string', blob);
126 } else {
127 ipcRenderer.send('download-file', { url: props.srcURL });
128 }
129 },
130 }, {
131 type: 'separator',
132 });
133 }
134
135 console.log('suggestions', suggestions.length, suggestions);
136 if (suggestions.length > 0) {
137 suggestions.reverse().map(suggestion => menuTpl.unshift({
138 id: `suggestion-${suggestion}`,
139 label: suggestion,
140 click() {
141 webContents.replaceMisspelling(suggestion);
142 },
143 }));
144 }
145
146 if (isDevMode) {
147 menuTpl.push({
148 type: 'separator',
149 }, {
150 id: 'inspect',
151 label: 'Inspect Element',
152 click() {
153 webContents.inspectElement(props.x, props.y);
154 },
155 }, {
156 type: 'separator',
157 });
158 }
159
160 return delUnusedElements(menuTpl);
161};
162
163export default function contextMenu(spellcheckProvider) {
164 webContents.on('context-menu', (e, props) => {
165 e.preventDefault();
166
167 let suggestions = [];
168 if (spellcheckProvider && props.misspelledWord) {
169 suggestions = spellcheckProvider.getSuggestion(props.misspelledWord);
170
171 debug('Suggestions', suggestions);
172 }
173
174 const menu = Menu.buildFromTemplate(buildMenuTpl(props, suggestions.slice(0, 5)));
175
176 menu.popup(remote.getCurrentWindow());
177 });
178}
diff --git a/src/webview/darkmode.js b/src/webview/darkmode.js
new file mode 100644
index 000000000..9830ef33c
--- /dev/null
+++ b/src/webview/darkmode.js
@@ -0,0 +1,28 @@
1import path from 'path';
2import fs from 'fs-extra';
3
4const ID = 'franz-theme-dark-mode';
5
6export function injectDarkModeStyle(recipePath) {
7 const darkModeStyle = path.join(recipePath, 'darkmode.css');
8 if (fs.pathExistsSync(darkModeStyle)) {
9 const data = fs.readFileSync(darkModeStyle);
10 const styles = document.createElement('style');
11 styles.id = ID;
12 styles.innerHTML = data.toString();
13
14 document.querySelector('head').appendChild(styles);
15 }
16}
17
18export function removeDarkModeStyle() {
19 const style = document.querySelector(`#${ID}`);
20
21 if (style) {
22 style.remove();
23 }
24}
25
26export function isDarkModeStyleInjected() {
27 return !!document.querySelector(`#${ID}`);
28}
diff --git a/src/webview/plugin.js b/src/webview/plugin.js
index c6530fef6..72530733d 100644
--- a/src/webview/plugin.js
+++ b/src/webview/plugin.js
@@ -1,14 +1,17 @@
1import { ipcRenderer } from 'electron'; 1import { ipcRenderer } from 'electron';
2import { ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker';
3import path from 'path'; 2import path from 'path';
4 3
5import { isDevMode } from '../environment';
6import RecipeWebview from './lib/RecipeWebview'; 4import RecipeWebview from './lib/RecipeWebview';
7 5
8import Spellchecker from './spellchecker'; 6import spellchecker, { switchDict, disable as disableSpellchecker } from './spellchecker';
7import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode';
8import contextMenu from './contextMenu';
9import './notifications'; 9import './notifications';
10 10
11const debug = require('debug')('Plugin'); 11const debug = require('debug')('Franz:Plugin');
12
13window.franzSettings = {};
14let serviceData;
12 15
13ipcRenderer.on('initializeRecipe', (e, data) => { 16ipcRenderer.on('initializeRecipe', (e, data) => {
14 const modulePath = path.join(data.recipe.path, 'webview.js'); 17 const modulePath = path.join(data.recipe.path, 'webview.js');
@@ -17,27 +20,58 @@ ipcRenderer.on('initializeRecipe', (e, data) => {
17 try { 20 try {
18 // eslint-disable-next-line 21 // eslint-disable-next-line
19 require(modulePath)(new RecipeWebview(), data); 22 require(modulePath)(new RecipeWebview(), data);
20 debug('Initialize Recipe'); 23 debug('Initialize Recipe', data);
24
25 serviceData = data;
26
27 if (data.isDarkModeEnabled) {
28 injectDarkModeStyle(data.recipe.path);
29 debug('Add dark theme styles');
30 }
21 } catch (err) { 31 } catch (err) {
22 debug('Recipe initialization failed', err); 32 debug('Recipe initialization failed', err);
23 } 33 }
24}); 34});
25 35
26const spellchecker = new Spellchecker(); 36// Needs to run asap to intialize dictionaries
27spellchecker.initialize(); 37(async () => {
38 const spellcheckingProvider = await spellchecker();
39 contextMenu(spellcheckingProvider);
40})();
28 41
29const contextMenuBuilder = new ContextMenuBuilder(spellchecker.handler, null, isDevMode); 42ipcRenderer.on('settings-update', async (e, data) => {
43 debug('Settings update received', data);
30 44
31new ContextMenuListener((info) => { // eslint-disable-line 45 if (data.enableSpellchecking) {
32 contextMenuBuilder.showPopupMenu(info); 46 switchDict(data.spellcheckerLanguage);
47 } else {
48 disableSpellchecker();
49 }
50
51 window.franzSettings = data;
33}); 52});
34 53
35ipcRenderer.on('settings-update', (e, data) => { 54ipcRenderer.on('service-settings-update', (e, data) => {
36 spellchecker.toggleSpellchecker(data.enableSpellchecking); 55 debug('Service settings update received', data);
37 debug('Settings update received', data); 56
57 if (data.isDarkModeEnabled && !isDarkModeStyleInjected()) {
58 injectDarkModeStyle(serviceData.recipe.path);
59
60 debug('Enable service dark mode');
61 } else if (!data.isDarkModeEnabled && isDarkModeStyleInjected()) {
62 removeDarkModeStyle();
63
64 debug('Disable service dark mode');
65 }
66});
67
68// Needed for current implementation of electrons 'login' event 🤦‍
69ipcRenderer.on('get-service-id', (event) => {
70 debug('Asking for service id', event);
71
72 event.sender.send('service-id', serviceData.id);
38}); 73});
39 74
40// initSpellche
41 75
42document.addEventListener('DOMContentLoaded', () => { 76document.addEventListener('DOMContentLoaded', () => {
43 ipcRenderer.sendToHost('hello'); 77 ipcRenderer.sendToHost('hello');
@@ -47,7 +81,7 @@ document.addEventListener('DOMContentLoaded', () => {
47const originalWindowOpen = window.open; 81const originalWindowOpen = window.open;
48 82
49window.open = (url, frameName, features) => { 83window.open = (url, frameName, features) => {
50 // We need to differentiate if the link should be opened in a popup or in the systems default browser 84 // We need to differentiate if the link should be opened in a popup or in the systems default browser
51 if (!frameName && !features) { 85 if (!frameName && !features) {
52 return ipcRenderer.sendToHost('new-window', url); 86 return ipcRenderer.sendToHost('new-window', url);
53 } 87 }
diff --git a/src/webview/spellchecker.js b/src/webview/spellchecker.js
index a504a4039..b0192b7ef 100644
--- a/src/webview/spellchecker.js
+++ b/src/webview/spellchecker.js
@@ -1,63 +1,92 @@
1import { SpellCheckHandler } from 'electron-spellchecker'; 1import { webFrame } from 'electron';
2import fs from 'fs';
3import path from 'path';
4import { SpellCheckerProvider } from 'electron-hunspell';
2 5
3import { isMac } from '../environment'; 6import { DICTIONARY_PATH } from '../config';
4 7
5export default class Spellchecker { 8const debug = require('debug')('Franz:spellchecker');
6 isInitialized = false;
7 handler = null;
8 initRetries = 0;
9 DOMCheckInterval = null;
10 9
11 get inputs() { 10let provider;
12 return document.querySelectorAll('input[type="text"], [contenteditable="true"], textarea'); 11let currentDict;
13 } 12let _isEnabled = false;
14 13
15 initialize() { 14async function loadDictionaries() {
16 this.handler = new SpellCheckHandler(); 15 const rawList = fs.readdirSync(DICTIONARY_PATH);
17 16
18 if (!isMac) { 17 const dicts = rawList.filter(item => !item.startsWith('.') && fs.lstatSync(path.join(DICTIONARY_PATH, item)).isDirectory());
19 this.attach(); 18
20 } else { 19 debug('Found dictionaries', dicts);
21 this.isInitialized = true; 20
22 } 21 for (let i = 0; i < dicts.length; i += 1) {
22 const fileLocation = `${DICTIONARY_PATH}/${dicts[i]}/${dicts[i]}`;
23 debug('Trying to load', fileLocation);
24 // eslint-disable-next-line
25 await provider.loadDictionary(dicts[i], `${fileLocation}.dic`, `${fileLocation}.aff`);
23 } 26 }
27}
28
29export async function switchDict(locale) {
30 try {
31 debug('Trying to load dictionary', locale);
24 32
25 attach() { 33 if (!provider.availableDictionaries.includes(locale)) {
26 let initFailed = false; 34 console.warn('Dict not available', locale);
27 35
28 if (this.initRetries > 3) {
29 console.error('Could not initialize spellchecker');
30 return; 36 return;
31 } 37 }
32 38
33 try { 39 if (!provider) {
34 this.handler.attachToInput(); 40 console.warn('SpellcheckProvider not initialized');
35 this.handler.switchLanguage(navigator.language); 41
36 } catch (err) { 42 return;
37 initFailed = true;
38 this.initRetries = +1;
39 setTimeout(() => { this.attach(); console.warn('Spellchecker init failed, trying again in 5s'); }, 5000);
40 } 43 }
41 44
42 if (!initFailed) { 45 if (locale === currentDict) {
43 this.isInitialized = true; 46 console.warn('Dictionary is already used', currentDict);
47
48 return;
44 } 49 }
45 }
46 50
47 toggleSpellchecker(enable = false) { 51 provider.switchDictionary(locale);
48 this.inputs.forEach((input) => {
49 input.setAttribute('spellcheck', enable);
50 });
51 52
52 this.intervalHandler(enable); 53 debug('Switched dictionary to', locale);
54
55 currentDict = locale;
56 _isEnabled = true;
57 } catch (err) {
58 console.error(err);
53 } 59 }
60}
54 61
55 intervalHandler(enable) { 62export default async function initialize(languageCode = 'en-us') {
56 clearInterval(this.DOMCheckInterval); 63 try {
64 provider = new SpellCheckerProvider();
65 const locale = languageCode.toLowerCase();
57 66
58 if (enable) { 67 debug('Init spellchecker');
59 this.DOMCheckInterval = setInterval(() => this.toggleSpellchecker(enable), 30000); 68 await provider.initialize();
60 } 69 await loadDictionaries();
70
71 debug('Available spellchecker dictionaries', provider.availableDictionaries);
72
73 switchDict(locale);
74
75 return provider;
76 } catch (err) {
77 console.error(err);
78 return false;
61 } 79 }
62} 80}
63 81
82export function isEnabled() {
83 return _isEnabled;
84}
85
86export function disable() {
87 if (isEnabled()) {
88 webFrame.setSpellCheckProvider(currentDict, true, { spellCheck: () => true });
89 _isEnabled = false;
90 currentDict = null;
91 }
92}