diff options
author | Stefan Malzner <stefan@adlk.io> | 2018-12-02 15:08:07 +0100 |
---|---|---|
committer | Stefan Malzner <stefan@adlk.io> | 2018-12-02 15:08:07 +0100 |
commit | a5aa5e1380a0847eb33be3315d164fb9e0e23255 (patch) | |
tree | a1ea4b0f635cabb54d9cf028809d2f079727b9a1 /src/webview | |
parent | Add instruction to install Franz with homebrew (#1134) (diff) | |
parent | Merge branch 'develop' into release/5.0.0-beta.19 (diff) | |
download | ferdium-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.js | 178 | ||||
-rw-r--r-- | src/webview/darkmode.js | 28 | ||||
-rw-r--r-- | src/webview/plugin.js | 64 | ||||
-rw-r--r-- | src/webview/spellchecker.js | 111 |
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 | |||
4 | import { clipboard, remote, ipcRenderer, shell } from 'electron'; | ||
5 | |||
6 | import { isDevMode } from '../environment'; | ||
7 | |||
8 | const debug = require('debug')('Franz:contextMenu'); | ||
9 | |||
10 | const { Menu } = remote; | ||
11 | |||
12 | // const win = remote.getCurrentWindow(); | ||
13 | const webContents = remote.getCurrentWebContents(); | ||
14 | |||
15 | function 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 | |||
24 | const 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 | |||
163 | export 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 @@ | |||
1 | import path from 'path'; | ||
2 | import fs from 'fs-extra'; | ||
3 | |||
4 | const ID = 'franz-theme-dark-mode'; | ||
5 | |||
6 | export 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 | |||
18 | export function removeDarkModeStyle() { | ||
19 | const style = document.querySelector(`#${ID}`); | ||
20 | |||
21 | if (style) { | ||
22 | style.remove(); | ||
23 | } | ||
24 | } | ||
25 | |||
26 | export 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 @@ | |||
1 | import { ipcRenderer } from 'electron'; | 1 | import { ipcRenderer } from 'electron'; |
2 | import { ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker'; | ||
3 | import path from 'path'; | 2 | import path from 'path'; |
4 | 3 | ||
5 | import { isDevMode } from '../environment'; | ||
6 | import RecipeWebview from './lib/RecipeWebview'; | 4 | import RecipeWebview from './lib/RecipeWebview'; |
7 | 5 | ||
8 | import Spellchecker from './spellchecker'; | 6 | import spellchecker, { switchDict, disable as disableSpellchecker } from './spellchecker'; |
7 | import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode'; | ||
8 | import contextMenu from './contextMenu'; | ||
9 | import './notifications'; | 9 | import './notifications'; |
10 | 10 | ||
11 | const debug = require('debug')('Plugin'); | 11 | const debug = require('debug')('Franz:Plugin'); |
12 | |||
13 | window.franzSettings = {}; | ||
14 | let serviceData; | ||
12 | 15 | ||
13 | ipcRenderer.on('initializeRecipe', (e, data) => { | 16 | ipcRenderer.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 | ||
26 | const spellchecker = new Spellchecker(); | 36 | // Needs to run asap to intialize dictionaries |
27 | spellchecker.initialize(); | 37 | (async () => { |
38 | const spellcheckingProvider = await spellchecker(); | ||
39 | contextMenu(spellcheckingProvider); | ||
40 | })(); | ||
28 | 41 | ||
29 | const contextMenuBuilder = new ContextMenuBuilder(spellchecker.handler, null, isDevMode); | 42 | ipcRenderer.on('settings-update', async (e, data) => { |
43 | debug('Settings update received', data); | ||
30 | 44 | ||
31 | new 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 | ||
35 | ipcRenderer.on('settings-update', (e, data) => { | 54 | ipcRenderer.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 🤦 | ||
69 | ipcRenderer.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 | ||
42 | document.addEventListener('DOMContentLoaded', () => { | 76 | document.addEventListener('DOMContentLoaded', () => { |
43 | ipcRenderer.sendToHost('hello'); | 77 | ipcRenderer.sendToHost('hello'); |
@@ -47,7 +81,7 @@ document.addEventListener('DOMContentLoaded', () => { | |||
47 | const originalWindowOpen = window.open; | 81 | const originalWindowOpen = window.open; |
48 | 82 | ||
49 | window.open = (url, frameName, features) => { | 83 | window.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 @@ | |||
1 | import { SpellCheckHandler } from 'electron-spellchecker'; | 1 | import { webFrame } from 'electron'; |
2 | import fs from 'fs'; | ||
3 | import path from 'path'; | ||
4 | import { SpellCheckerProvider } from 'electron-hunspell'; | ||
2 | 5 | ||
3 | import { isMac } from '../environment'; | 6 | import { DICTIONARY_PATH } from '../config'; |
4 | 7 | ||
5 | export default class Spellchecker { | 8 | const debug = require('debug')('Franz:spellchecker'); |
6 | isInitialized = false; | ||
7 | handler = null; | ||
8 | initRetries = 0; | ||
9 | DOMCheckInterval = null; | ||
10 | 9 | ||
11 | get inputs() { | 10 | let provider; |
12 | return document.querySelectorAll('input[type="text"], [contenteditable="true"], textarea'); | 11 | let currentDict; |
13 | } | 12 | let _isEnabled = false; |
14 | 13 | ||
15 | initialize() { | 14 | async 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 | |||
29 | export 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) { | 62 | export 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 | ||
82 | export function isEnabled() { | ||
83 | return _isEnabled; | ||
84 | } | ||
85 | |||
86 | export function disable() { | ||
87 | if (isEnabled()) { | ||
88 | webFrame.setSpellCheckProvider(currentDict, true, { spellCheck: () => true }); | ||
89 | _isEnabled = false; | ||
90 | currentDict = null; | ||
91 | } | ||
92 | } | ||