diff options
author | 2018-11-30 14:32:45 +0100 | |
---|---|---|
committer | 2018-11-30 14:32:45 +0100 | |
commit | 3d87c0e45cead95ddb6c11fc6540b82e375bdcf5 (patch) | |
tree | c91f425a39cb585242d6df5b4070de4a2141b3b4 /src/webview | |
parent | Merge branch 'update/monetization' into develop (diff) | |
download | ferdium-app-3d87c0e45cead95ddb6c11fc6540b82e375bdcf5.tar.gz ferdium-app-3d87c0e45cead95ddb6c11fc6540b82e375bdcf5.tar.zst ferdium-app-3d87c0e45cead95ddb6c11fc6540b82e375bdcf5.zip |
feat(App): Improved spell checker & context menu
Diffstat (limited to 'src/webview')
-rw-r--r-- | src/webview/contextMenu.js | 175 | ||||
-rw-r--r-- | src/webview/plugin.js | 29 | ||||
-rw-r--r-- | src/webview/spellchecker.js | 111 |
3 files changed, 260 insertions, 55 deletions
diff --git a/src/webview/contextMenu.js b/src/webview/contextMenu.js new file mode 100644 index 000000000..4dda51bde --- /dev/null +++ b/src/webview/contextMenu.js | |||
@@ -0,0 +1,175 @@ | |||
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 | let menuTpl = [ | ||
30 | { | ||
31 | type: 'separator', | ||
32 | }, { | ||
33 | id: 'cut', | ||
34 | role: can('Cut') ? 'cut' : '', | ||
35 | enabled: can('Cut'), | ||
36 | visible: props.isEditable, | ||
37 | }, { | ||
38 | id: 'copy', | ||
39 | label: 'Copy', | ||
40 | role: can('Copy') ? 'copy' : '', | ||
41 | enabled: can('Copy'), | ||
42 | visible: props.isEditable || hasText, | ||
43 | }, { | ||
44 | id: 'paste', | ||
45 | label: 'Paste', | ||
46 | role: editFlags.canPaste ? 'paste' : '', | ||
47 | enabled: editFlags.canPaste, | ||
48 | visible: props.isEditable, | ||
49 | }, { | ||
50 | type: 'separator', | ||
51 | }, | ||
52 | ]; | ||
53 | |||
54 | if (props.linkURL && props.mediaType === 'none') { | ||
55 | menuTpl = [{ | ||
56 | type: 'separator', | ||
57 | }, { | ||
58 | id: 'openLink', | ||
59 | label: 'Open Link in Browser', | ||
60 | click() { | ||
61 | shell.openExternal(props.linkURL); | ||
62 | }, | ||
63 | }, { | ||
64 | id: 'copyLink', | ||
65 | label: 'Copy Link', | ||
66 | click() { | ||
67 | clipboard.write({ | ||
68 | bookmark: props.linkText, | ||
69 | text: props.linkURL, | ||
70 | }); | ||
71 | }, | ||
72 | }, { | ||
73 | type: 'separator', | ||
74 | }]; | ||
75 | } | ||
76 | |||
77 | if (props.mediaType === 'image') { | ||
78 | menuTpl.push({ | ||
79 | type: 'separator', | ||
80 | }, { | ||
81 | id: 'openImage', | ||
82 | label: 'Open Image in Browser', | ||
83 | click() { | ||
84 | shell.openExternal(props.srcURL); | ||
85 | }, | ||
86 | }, { | ||
87 | id: 'copyImageAddress', | ||
88 | label: 'Copy Image Address', | ||
89 | click() { | ||
90 | clipboard.write({ | ||
91 | bookmark: props.srcURL, | ||
92 | text: props.srcURL, | ||
93 | }); | ||
94 | }, | ||
95 | }, { | ||
96 | type: 'separator', | ||
97 | }); | ||
98 | } | ||
99 | |||
100 | if (props.mediaType === 'image') { | ||
101 | menuTpl.push({ | ||
102 | id: 'saveImageAs', | ||
103 | label: 'Save Image As…', | ||
104 | async click() { | ||
105 | if (props.srcURL.startsWith('blob:')) { | ||
106 | const url = new window.URL(props.srcURL.substr(5)); | ||
107 | const fileName = url.pathname.substr(1); | ||
108 | const resp = await window.fetch(props.srcURL); | ||
109 | const blob = await resp.blob(); | ||
110 | const reader = new window.FileReader(); | ||
111 | reader.readAsDataURL(blob); | ||
112 | reader.onloadend = () => { | ||
113 | const base64data = reader.result; | ||
114 | |||
115 | ipcRenderer.send('download-file', { | ||
116 | content: base64data, | ||
117 | fileOptions: { | ||
118 | name: fileName, | ||
119 | mime: blob.type, | ||
120 | }, | ||
121 | }); | ||
122 | }; | ||
123 | debug('binary string', blob); | ||
124 | } else { | ||
125 | ipcRenderer.send('download-file', { url: props.srcURL }); | ||
126 | } | ||
127 | }, | ||
128 | }, { | ||
129 | type: 'separator', | ||
130 | }); | ||
131 | } | ||
132 | |||
133 | if (suggestions.length > 0) { | ||
134 | suggestions.reverse().map(suggestion => menuTpl.unshift({ | ||
135 | id: `suggestion-${suggestion}`, | ||
136 | label: suggestion, | ||
137 | click() { | ||
138 | webContents.replaceMisspelling(suggestion); | ||
139 | }, | ||
140 | })); | ||
141 | } | ||
142 | |||
143 | if (isDevMode) { | ||
144 | menuTpl.push({ | ||
145 | type: 'separator', | ||
146 | }, { | ||
147 | id: 'inspect', | ||
148 | label: 'Inspect Element', | ||
149 | click() { | ||
150 | webContents.inspectElement(props.x, props.y); | ||
151 | }, | ||
152 | }, { | ||
153 | type: 'separator', | ||
154 | }); | ||
155 | } | ||
156 | |||
157 | return delUnusedElements(menuTpl); | ||
158 | }; | ||
159 | |||
160 | export default function contextMenu(spellcheckProvider) { | ||
161 | webContents.on('context-menu', (e, props) => { | ||
162 | e.preventDefault(); | ||
163 | |||
164 | let suggestions = []; | ||
165 | if (spellcheckProvider && props.misspelledWord) { | ||
166 | suggestions = spellcheckProvider.getSuggestion(props.misspelledWord); | ||
167 | |||
168 | debug('Suggestions', suggestions); | ||
169 | } | ||
170 | |||
171 | const menu = Menu.buildFromTemplate(buildMenuTpl(props, suggestions.slice(0, 5))); | ||
172 | |||
173 | menu.popup(remote.getCurrentWindow()); | ||
174 | }); | ||
175 | } | ||
diff --git a/src/webview/plugin.js b/src/webview/plugin.js index 427ec75ad..72530733d 100644 --- a/src/webview/plugin.js +++ b/src/webview/plugin.js | |||
@@ -1,12 +1,11 @@ | |||
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'; |
9 | import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode'; | 7 | import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode'; |
8 | import contextMenu from './contextMenu'; | ||
10 | import './notifications'; | 9 | import './notifications'; |
11 | 10 | ||
12 | const debug = require('debug')('Franz:Plugin'); | 11 | const debug = require('debug')('Franz:Plugin'); |
@@ -34,19 +33,21 @@ ipcRenderer.on('initializeRecipe', (e, data) => { | |||
34 | } | 33 | } |
35 | }); | 34 | }); |
36 | 35 | ||
37 | const spellchecker = new Spellchecker(); | 36 | // Needs to run asap to intialize dictionaries |
38 | spellchecker.initialize(); | 37 | (async () => { |
38 | const spellcheckingProvider = await spellchecker(); | ||
39 | contextMenu(spellcheckingProvider); | ||
40 | })(); | ||
39 | 41 | ||
40 | const contextMenuBuilder = new ContextMenuBuilder(spellchecker.handler, null, isDevMode); | 42 | ipcRenderer.on('settings-update', async (e, data) => { |
41 | |||
42 | new ContextMenuListener((info) => { // eslint-disable-line | ||
43 | contextMenuBuilder.showPopupMenu(info); | ||
44 | }); | ||
45 | |||
46 | ipcRenderer.on('settings-update', (e, data) => { | ||
47 | debug('Settings update received', data); | 43 | debug('Settings update received', data); |
48 | 44 | ||
49 | spellchecker.toggleSpellchecker(data.enableSpellchecking); | 45 | if (data.enableSpellchecking) { |
46 | switchDict(data.spellcheckerLanguage); | ||
47 | } else { | ||
48 | disableSpellchecker(); | ||
49 | } | ||
50 | |||
50 | window.franzSettings = data; | 51 | window.franzSettings = data; |
51 | }); | 52 | }); |
52 | 53 | ||
@@ -64,7 +65,7 @@ ipcRenderer.on('service-settings-update', (e, data) => { | |||
64 | } | 65 | } |
65 | }); | 66 | }); |
66 | 67 | ||
67 | // Needed for current implementation of electrons 'login' event | 68 | // Needed for current implementation of electrons 'login' event 🤦 |
68 | ipcRenderer.on('get-service-id', (event) => { | 69 | ipcRenderer.on('get-service-id', (event) => { |
69 | debug('Asking for service id', event); | 70 | debug('Asking for service id', event); |
70 | 71 | ||
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 | } | ||