Electron built-in spell checker
This commit is contained in:
parent
165007d1c1
commit
a599e17e1b
|
@ -35,6 +35,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import Sortable = require('sortablejs'); //tslint:disable-line:no-require-imports
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import {Component, Hook} from '@f-list/vue-ts';
|
||||
import * as electron from 'electron';
|
||||
|
@ -44,6 +45,7 @@
|
|||
import Vue from 'vue';
|
||||
import l from '../chat/localize';
|
||||
import {GeneralSettings} from './common';
|
||||
import { getSafeLanguages } from './language';
|
||||
|
||||
const browserWindow = electron.remote.getCurrentWindow();
|
||||
|
||||
|
@ -87,15 +89,26 @@
|
|||
// top bar devtools
|
||||
// browserWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
|
||||
browserWindow.webContents.session.setSpellCheckerLanguages(getSafeLanguages(this.settings.spellcheckLang));
|
||||
|
||||
await this.addTab();
|
||||
|
||||
electron.ipcRenderer.on('settings', (_: Event, settings: GeneralSettings) => this.settings = settings);
|
||||
electron.ipcRenderer.on('allow-new-tabs', (_: Event, allow: boolean) => this.canOpenTab = allow);
|
||||
electron.ipcRenderer.on('settings', (_e: Event, settings: GeneralSettings) => this.settings = settings);
|
||||
electron.ipcRenderer.on('allow-new-tabs', (_e: Event, allow: boolean) => this.canOpenTab = allow);
|
||||
electron.ipcRenderer.on('open-tab', () => this.addTab());
|
||||
electron.ipcRenderer.on('update-available', (_: Event, available: boolean) => this.hasUpdate = available);
|
||||
electron.ipcRenderer.on('update-available', (_e: Event, available: boolean) => this.hasUpdate = available);
|
||||
electron.ipcRenderer.on('fix-logs', () => this.activeTab!.view.webContents.send('fix-logs'));
|
||||
electron.ipcRenderer.on('quit', () => this.destroyAllTabs());
|
||||
electron.ipcRenderer.on('connect', (_: Event, id: number, name: string) => {
|
||||
|
||||
electron.ipcRenderer.on('update-dictionaries', (_e: Event, langs: string[]) => {
|
||||
browserWindow.webContents.session.setSpellCheckerLanguages(langs);
|
||||
|
||||
for (const t of this.tabs) {
|
||||
t.view.webContents.session.setSpellCheckerLanguages(langs);
|
||||
}
|
||||
});
|
||||
|
||||
electron.ipcRenderer.on('connect', (_e: Event, id: number, name: string) => {
|
||||
const tab = this.tabMap[id];
|
||||
tab.user = name;
|
||||
tab.tray.setToolTip(`${l('title')} - ${tab.user}`);
|
||||
|
@ -103,7 +116,7 @@
|
|||
menu.unshift({label: tab.user, enabled: false}, {type: 'separator'});
|
||||
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(menu));
|
||||
});
|
||||
electron.ipcRenderer.on('disconnect', (_: Event, id: number) => {
|
||||
electron.ipcRenderer.on('disconnect', (_e: Event, id: number) => {
|
||||
const tab = this.tabMap[id];
|
||||
if(tab.hasNew) {
|
||||
tab.hasNew = false;
|
||||
|
@ -113,18 +126,18 @@
|
|||
tab.tray.setToolTip(l('title'));
|
||||
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab)));
|
||||
});
|
||||
electron.ipcRenderer.on('has-new', (_: Event, id: number, hasNew: boolean) => {
|
||||
electron.ipcRenderer.on('has-new', (_e: Event, id: number, hasNew: boolean) => {
|
||||
const tab = this.tabMap[id];
|
||||
tab.hasNew = hasNew;
|
||||
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
|
||||
});
|
||||
browserWindow.on('maximize', () => this.isMaximized = true);
|
||||
browserWindow.on('unmaximize', () => this.isMaximized = false);
|
||||
electron.ipcRenderer.on('switch-tab', (_: Event) => {
|
||||
electron.ipcRenderer.on('switch-tab', (_e: Event) => {
|
||||
const index = this.tabs.indexOf(this.activeTab!);
|
||||
this.show(this.tabs[index + 1 === this.tabs.length ? 0 : index + 1]);
|
||||
});
|
||||
electron.ipcRenderer.on('show-tab', (_: Event, id: number) => {
|
||||
electron.ipcRenderer.on('show-tab', (_e: Event, id: number) => {
|
||||
this.show(this.tabMap[id]);
|
||||
});
|
||||
document.addEventListener('click', () => this.activeTab!.view.webContents.focus());
|
||||
|
@ -195,12 +208,14 @@
|
|||
if(this.lockTab) return;
|
||||
const tray = new electron.remote.Tray(trayIcon);
|
||||
tray.setToolTip(l('title'));
|
||||
tray.on('click', (_) => this.trayClicked(tab));
|
||||
tray.on('click', (_e) => this.trayClicked(tab));
|
||||
const view = new electron.remote.BrowserView({webPreferences: {webviewTag: true, nodeIntegration: true, spellcheck: true}});
|
||||
|
||||
// tab devtools
|
||||
// view.webContents.openDevTools();
|
||||
|
||||
view.webContents.session.setSpellCheckerLanguages(getSafeLanguages(this.settings.spellcheckLang));
|
||||
|
||||
view.setAutoResize({width: true, height: true});
|
||||
electron.ipcRenderer.send('tab-added', view.webContents.id);
|
||||
const tab = {active: false, view, user: undefined, hasNew: false, tray};
|
||||
|
|
|
@ -161,17 +161,17 @@ webContents.on('context-menu', (_, props) => {
|
|||
click: () => electron.clipboard.writeText(props.selectionText)
|
||||
});
|
||||
if(props.misspelledWord !== '') {
|
||||
// const corrections = spellchecker.getCorrectionsForMisspelling(props.misspelledWord);
|
||||
// menuTemplate.unshift({
|
||||
// label: l('spellchecker.add'),
|
||||
// click: () => electron.ipcRenderer.send('dictionary-add', props.misspelledWord)
|
||||
// }, {type: 'separator'});
|
||||
// if(corrections.length > 0)
|
||||
// menuTemplate.unshift(...corrections.map((correction: string) => ({
|
||||
// label: correction,
|
||||
// click: () => webContents.replaceMisspelling(correction)
|
||||
// })));
|
||||
// else menuTemplate.unshift({enabled: false, label: l('spellchecker.noCorrections')});
|
||||
const corrections = props.dictionarySuggestions; //spellchecker.getCorrectionsForMisspelling(props.misspelledWord);
|
||||
menuTemplate.unshift({
|
||||
label: l('spellchecker.add'),
|
||||
click: () => electron.ipcRenderer.send('dictionary-add', props.misspelledWord)
|
||||
}, {type: 'separator'});
|
||||
if(corrections.length > 0)
|
||||
menuTemplate.unshift(...corrections.map((correction: string) => ({
|
||||
label: correction,
|
||||
click: () => webContents.replaceMisspelling(correction)
|
||||
})));
|
||||
else menuTemplate.unshift({enabled: false, label: l('spellchecker.noCorrections')});
|
||||
} else if(settings.customDictionary.indexOf(props.selectionText) !== -1)
|
||||
menuTemplate.unshift({
|
||||
label: l('spellchecker.remove'),
|
||||
|
|
|
@ -9,7 +9,7 @@ export class GeneralSettings {
|
|||
profileViewer = true;
|
||||
host = defaultHost;
|
||||
logDirectory = path.join(electron.app.getPath('userData'), 'data');
|
||||
spellcheckLang: string | undefined = 'en_GB';
|
||||
spellcheckLang: string[] | string | undefined = 'en_GB';
|
||||
theme = 'default';
|
||||
version = electron.app.getVersion();
|
||||
beta = false;
|
||||
|
@ -24,4 +24,4 @@ export function nativeRequire<T>(module: string): T {
|
|||
return Module.prototype.require.call({paths: Module._nodeModulePaths(__dirname)}, module);
|
||||
}
|
||||
|
||||
//tslint:enable
|
||||
//tslint:enable
|
||||
|
|
|
@ -4,6 +4,7 @@ import log from 'electron-log'; //tslint:disable-line:match-default-export-name
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {promisify} from 'util';
|
||||
import * as _ from 'lodash';
|
||||
|
||||
const dictDir = path.join(electron.app.getPath('userData'), 'spellchecker');
|
||||
fs.mkdirSync(dictDir, {recursive: true});
|
||||
|
@ -46,4 +47,4 @@ export async function ensureDictionary(lang: string): Promise<void> {
|
|||
}
|
||||
await ensure('aff');
|
||||
await ensure('dic');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import * as _ from 'lodash';
|
||||
|
||||
|
||||
export function getSafeLanguages(langs: string | string[] | undefined): string[] {
|
||||
return langs ? _.castArray(langs) : [];
|
||||
}
|
||||
|
||||
|
||||
export const knownLanguageNames = {
|
||||
af: 'Afrikaans',
|
||||
bg: 'Bulgarian',
|
||||
ca: 'Catalan',
|
||||
cs: 'Czech',
|
||||
cy: 'Welsh',
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
el: 'Greek',
|
||||
|
||||
'en-AU': 'English, Australian',
|
||||
'en-CA': 'English, Canadian',
|
||||
'en-GB': 'English, British',
|
||||
'en-US': 'English, American',
|
||||
|
||||
es: 'Spanish',
|
||||
'es-419': 'Spanish, Latin America and Caribbean',
|
||||
'es-AR': 'Spanish, Argentine',
|
||||
'es-ES': 'Spanish, Castilian',
|
||||
'es-MX': 'Spanish, Mexican',
|
||||
'es-US': 'Spanish, American',
|
||||
|
||||
et: 'Estonian',
|
||||
fa: 'Persian',
|
||||
fi: 'Finnish',
|
||||
fo: 'Faroese',
|
||||
fr: 'French',
|
||||
he: 'Hebrew',
|
||||
hi: 'Hindi',
|
||||
hr: 'Croatian',
|
||||
hu: 'Hungarian',
|
||||
hy: 'Armenian',
|
||||
id: 'Indonesian',
|
||||
it: 'Italian',
|
||||
ko: 'Korean',
|
||||
lt: 'Lithuanian',
|
||||
lv: 'Latvian',
|
||||
nb: 'Norwegian',
|
||||
nl: 'Dutch',
|
||||
pl: 'Polish',
|
||||
|
||||
'pt-BR': 'Portuguese, Brazilian',
|
||||
'pt-PT': 'Portuguese, European',
|
||||
|
||||
ro: 'Romanian',
|
||||
ru: 'Russian',
|
||||
sh: 'Serbo-Croatian',
|
||||
sk: 'Slovak',
|
||||
sl: 'Slovenian',
|
||||
sq: 'Albanian',
|
||||
sr: 'Serbian',
|
||||
sv: 'Swedish',
|
||||
ta: 'Tamil',
|
||||
tg: 'Tajik',
|
||||
tr: 'Turkish',
|
||||
uk: 'Ukranian',
|
||||
vi: 'Vietnamese'
|
||||
};
|
||||
|
156
electron/main.ts
156
electron/main.ts
|
@ -41,13 +41,14 @@ import * as path from 'path';
|
|||
// import * as url from 'url';
|
||||
import l from '../chat/localize';
|
||||
import {defaultHost, GeneralSettings} from './common';
|
||||
import {ensureDictionary, getAvailableDictionaries} from './dictionaries';
|
||||
import { getSafeLanguages, knownLanguageNames } from './language';
|
||||
import * as windowState from './window_state';
|
||||
import BrowserWindow = Electron.BrowserWindow;
|
||||
import MenuItem = Electron.MenuItem;
|
||||
import { ElectronBlocker } from '@cliqz/adblocker-electron';
|
||||
import fetch from 'node-fetch';
|
||||
import MenuItemConstructorOptions = Electron.MenuItemConstructorOptions;
|
||||
import * as _ from 'lodash';
|
||||
|
||||
// Module to control application life.
|
||||
const app = electron.app;
|
||||
|
@ -80,10 +81,46 @@ if(!settings.hwAcceleration) {
|
|||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
async function setDictionary(lang: string | undefined): Promise<void> {
|
||||
if(lang !== undefined) await ensureDictionary(lang);
|
||||
settings.spellcheckLang = lang;
|
||||
// async function setDictionary(lang: string | undefined): Promise<void> {
|
||||
// if(lang !== undefined) await ensureDictionary(lang);
|
||||
// settings.spellcheckLang = lang;
|
||||
// setGeneralSettings(settings);
|
||||
// }
|
||||
|
||||
|
||||
export function updateSpellCheckerLanguages(langs: string[]): void {
|
||||
// console.log('Language support:', langs);
|
||||
|
||||
for (const w of windows) {
|
||||
// console.log('LANG SEND');
|
||||
w.webContents.send('update-dictionaries', langs);
|
||||
}
|
||||
|
||||
electron.session.defaultSession.setSpellCheckerLanguages(langs);
|
||||
}
|
||||
|
||||
|
||||
async function toggleDictionary(lang: string): Promise<void> {
|
||||
const activeLangs = getSafeLanguages(settings.spellcheckLang);
|
||||
|
||||
// console.log('INITIAL LANG', activeLangs, lang);
|
||||
|
||||
let newLangs: string[] = [];
|
||||
|
||||
if (_.indexOf(activeLangs, lang) >= 0) {
|
||||
newLangs = _.reject(activeLangs, (al) => (al === lang));
|
||||
} else {
|
||||
activeLangs.push(lang);
|
||||
newLangs = activeLangs;
|
||||
}
|
||||
|
||||
settings.spellcheckLang = newLangs;
|
||||
|
||||
setGeneralSettings(settings);
|
||||
|
||||
// console.log('NEW LANG', newLangs);
|
||||
|
||||
updateSpellCheckerLanguages(newLangs);
|
||||
}
|
||||
|
||||
function setGeneralSettings(value: GeneralSettings): void {
|
||||
|
@ -93,20 +130,23 @@ function setGeneralSettings(value: GeneralSettings): void {
|
|||
}
|
||||
|
||||
async function addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
|
||||
if(settings.spellcheckLang !== undefined) await ensureDictionary(settings.spellcheckLang);
|
||||
const dictionaries = await getAvailableDictionaries();
|
||||
const selected = settings.spellcheckLang;
|
||||
menu.append(new electron.MenuItem({
|
||||
type: 'radio',
|
||||
label: l('settings.spellcheck.disabled'),
|
||||
click: async() => setDictionary(undefined)
|
||||
}));
|
||||
for(const lang of dictionaries)
|
||||
const selected = getSafeLanguages(settings.spellcheckLang);
|
||||
const langs = electron.session.defaultSession.availableSpellCheckerLanguages;
|
||||
|
||||
const sortedLangs = _.sortBy(
|
||||
_.map(
|
||||
langs,
|
||||
(lang) => ({lang, name: (lang in knownLanguageNames) ? `${(knownLanguageNames as any)[lang]} (${lang})` : lang})
|
||||
),
|
||||
'name'
|
||||
);
|
||||
|
||||
for (const lang of sortedLangs)
|
||||
menu.append(new electron.MenuItem({
|
||||
type: 'radio',
|
||||
label: lang,
|
||||
checked: lang === selected,
|
||||
click: async() => setDictionary(lang)
|
||||
type: 'checkbox',
|
||||
label: lang.name,
|
||||
checked: (_.indexOf(selected, lang.lang) >= 0),
|
||||
click: async() => toggleDictionary(lang.lang)
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -140,6 +180,9 @@ function createWindow(): Electron.BrowserWindow | undefined {
|
|||
const window = new electron.BrowserWindow(windowProperties);
|
||||
windows.push(window);
|
||||
|
||||
const safeLanguages = settings.spellcheckLang ? _.castArray(settings.spellcheckLang) : [];
|
||||
electron.session.defaultSession.setSpellCheckerLanguages(safeLanguages);
|
||||
|
||||
// tslint:disable-next-line:no-floating-promises
|
||||
ElectronBlocker.fromLists(
|
||||
fetch,
|
||||
|
@ -160,31 +203,31 @@ function createWindow(): Electron.BrowserWindow | undefined {
|
|||
(blocker) => {
|
||||
blocker.enableBlockingInSession(electron.session.defaultSession);
|
||||
|
||||
// console.log('Got this far!!!!');
|
||||
|
||||
blocker.on('request-blocked', (request: Request) => {
|
||||
console.log('blocked', request.url);
|
||||
});
|
||||
|
||||
blocker.on('request-redirected', (request: Request) => {
|
||||
console.log('redirected', request.url);
|
||||
});
|
||||
|
||||
blocker.on('request-whitelisted', (request: Request) => {
|
||||
console.log('whitelisted', request.url);
|
||||
});
|
||||
|
||||
blocker.on('csp-injected', (request: Request) => {
|
||||
console.log('csp', request.url);
|
||||
});
|
||||
|
||||
blocker.on('script-injected', (script: string, url: string) => {
|
||||
console.log('script', script.length, url);
|
||||
});
|
||||
|
||||
blocker.on('style-injected', (style: string, url: string) => {
|
||||
console.log('style', style.length, url);
|
||||
});
|
||||
// // console.log('Got this far!!!!');
|
||||
//
|
||||
// blocker.on('request-blocked', (request: Request) => {
|
||||
// console.log('blocked', request.url);
|
||||
// });
|
||||
//
|
||||
// blocker.on('request-redirected', (request: Request) => {
|
||||
// console.log('redirected', request.url);
|
||||
// });
|
||||
//
|
||||
// blocker.on('request-whitelisted', (request: Request) => {
|
||||
// console.log('whitelisted', request.url);
|
||||
// });
|
||||
//
|
||||
// blocker.on('csp-injected', (request: Request) => {
|
||||
// console.log('csp', request.url);
|
||||
// });
|
||||
//
|
||||
// blocker.on('script-injected', (script: string, url: string) => {
|
||||
// console.log('script', script.length, url);
|
||||
// });
|
||||
//
|
||||
// blocker.on('style-injected', (style: string, url: string) => {
|
||||
// console.log('style', style.length, url);
|
||||
// });
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -302,14 +345,14 @@ function onReady(): void {
|
|||
{label: l('action.newWindow'), click: createWindow, accelerator: 'CmdOrCtrl+n'},
|
||||
{
|
||||
label: l('action.newTab'),
|
||||
click: (_: Electron.MenuItem, w: Electron.BrowserWindow) => {
|
||||
click: (_m: Electron.MenuItem, w: Electron.BrowserWindow) => {
|
||||
if(tabCount < 3) w.webContents.send('open-tab');
|
||||
},
|
||||
accelerator: 'CmdOrCtrl+t'
|
||||
},
|
||||
{
|
||||
label: l('settings.logDir'),
|
||||
click: (_, window: BrowserWindow) => {
|
||||
click: (_m, window: BrowserWindow) => {
|
||||
const dir = electron.dialog.showOpenDialogSync(
|
||||
{defaultPath: settings.logDirectory, properties: ['openDirectory']});
|
||||
if(dir !== undefined) {
|
||||
|
@ -367,14 +410,14 @@ function onReady(): void {
|
|||
}
|
||||
}, {
|
||||
label: l('fixLogs.action'),
|
||||
click: (_, window: BrowserWindow) => window.webContents.send('fix-logs')
|
||||
click: (_m, window: BrowserWindow) => window.webContents.send('fix-logs')
|
||||
},
|
||||
{type: 'separator'},
|
||||
{role: 'minimize'},
|
||||
{
|
||||
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : undefined,
|
||||
label: l('action.quit'),
|
||||
click(_: Electron.MenuItem, window: Electron.BrowserWindow): void {
|
||||
click(_m: Electron.MenuItem, window: Electron.BrowserWindow): void {
|
||||
if(characters.length === 0) return app.quit();
|
||||
const button = electron.dialog.showMessageBoxSync(window, {
|
||||
message: l('chat.confirmLeave'),
|
||||
|
@ -426,7 +469,7 @@ function onReady(): void {
|
|||
]
|
||||
}
|
||||
]));
|
||||
electron.ipcMain.on('tab-added', (_: Event, id: number) => {
|
||||
electron.ipcMain.on('tab-added', (_event: Event, id: number) => {
|
||||
const webContents = electron.webContents.fromId(id);
|
||||
setUpWebContents(webContents);
|
||||
++tabCount;
|
||||
|
@ -437,7 +480,7 @@ function onReady(): void {
|
|||
--tabCount;
|
||||
for(const w of windows) w.webContents.send('allow-new-tabs', true);
|
||||
});
|
||||
electron.ipcMain.on('save-login', (_: Event, account: string, host: string) => {
|
||||
electron.ipcMain.on('save-login', (_event: Event, account: string, host: string) => {
|
||||
settings.account = account;
|
||||
settings.host = host;
|
||||
setGeneralSettings(settings);
|
||||
|
@ -447,16 +490,17 @@ function onReady(): void {
|
|||
characters.push(character);
|
||||
e.returnValue = true;
|
||||
});
|
||||
electron.ipcMain.on('dictionary-add', (_: Event, word: string) => {
|
||||
if(settings.customDictionary.indexOf(word) !== -1) return;
|
||||
settings.customDictionary.push(word);
|
||||
setGeneralSettings(settings);
|
||||
electron.ipcMain.on('dictionary-add', (_event: Event, word: string) => {
|
||||
// if(settings.customDictionary.indexOf(word) !== -1) return;
|
||||
// settings.customDictionary.push(word);
|
||||
// setGeneralSettings(settings);
|
||||
for(const w of windows) w.webContents.session.addWordToSpellCheckerDictionary(word);
|
||||
});
|
||||
electron.ipcMain.on('dictionary-remove', (_: Event, word: string) => {
|
||||
settings.customDictionary.splice(settings.customDictionary.indexOf(word), 1);
|
||||
setGeneralSettings(settings);
|
||||
electron.ipcMain.on('dictionary-remove', (_event: Event /*, word: string*/) => {
|
||||
// settings.customDictionary.splice(settings.customDictionary.indexOf(word), 1);
|
||||
// setGeneralSettings(settings);
|
||||
});
|
||||
electron.ipcMain.on('disconnect', (_: Event, character: string) => {
|
||||
electron.ipcMain.on('disconnect', (_event: Event, character: string) => {
|
||||
const index = characters.indexOf(character);
|
||||
if(index !== -1) characters.splice(index, 1);
|
||||
});
|
||||
|
|
|
@ -20,12 +20,13 @@ This repository contains a heavily customized version of the mainline F-Chat 3.0
|
|||
* Ad auto-posting
|
||||
* Manage channel ad settings via "Tab Settings"
|
||||
* Automatically re-post ads every 11-18 minutes (randomized) for up to 180 minutes
|
||||
* Rotate multiple ads on a single channel by entering multiple ads in "Tab Settings"
|
||||
* Rotate multiple ads on a single channel by entering multiple ads in "Ad Settings"
|
||||
* Ad ratings
|
||||
* LFP ads are automatically rated and matched against your profile
|
||||
* LFP ads are automatically rated (great/good/maybe/no) and matched against your profile
|
||||
* Link previews
|
||||
* Hover cursor over any `[url]` to see a preview of it
|
||||
* Middle click any `[url]` to turn the preview into a sticky / interactive mode
|
||||
* Link preview has an ad-blocker to minimize page load times and protect against unfriendly scripts
|
||||
* Profile
|
||||
* Kinks are auto-compared when viewing character profile
|
||||
* Custom kink explanations can be expanded inline
|
||||
|
@ -51,6 +52,10 @@ This repository contains a heavily customized version of the mainline F-Chat 3.0
|
|||
* Conversation dialog can be opened by typing in a character name
|
||||
* Message search matches character names
|
||||
* PM list shows characters' online status as a colored icon
|
||||
* Details for Nerds
|
||||
* Upgraded to Electron 8.x
|
||||
* Replaced node-spellchecker with the built-in spellchecker of Electron 8
|
||||
* Multi-language support for spell checking (Windows only – language support is fully automatic on MacOS)
|
||||
|
||||
|
||||
## How to Set Up Ads
|
||||
|
|
Loading…
Reference in New Issue