218 lines
6.7 KiB
TypeScript
218 lines
6.7 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {app, ipcMain, type Session, shell, type WebContents} from 'electron';
|
|
import log from 'electron-log';
|
|
|
|
type LanguageCode = string;
|
|
|
|
interface SpellcheckState {
|
|
enabled: boolean;
|
|
languages: Array<LanguageCode>;
|
|
}
|
|
|
|
interface RendererSpellcheckState {
|
|
enabled?: boolean;
|
|
languages?: Array<LanguageCode>;
|
|
}
|
|
|
|
const defaultState: SpellcheckState = {
|
|
enabled: true,
|
|
languages: [],
|
|
};
|
|
|
|
const isMac = process.platform === 'darwin';
|
|
const isWindows = process.platform === 'win32';
|
|
const showSpellMenu = isMac || isWindows;
|
|
|
|
const normalizeLanguage = (code: string): string => code.toLowerCase();
|
|
|
|
const contextSourceByWebContents = new WeakMap<WebContents, {isTextarea: boolean; ts: number}>();
|
|
let contextIpcRegistered = false;
|
|
|
|
const ensureContextIpc = () => {
|
|
if (contextIpcRegistered) return;
|
|
contextIpcRegistered = true;
|
|
|
|
ipcMain.on('spellcheck-context-target', (event, payload: {isTextarea?: boolean}) => {
|
|
contextSourceByWebContents.set(event.sender, {
|
|
isTextarea: Boolean(payload?.isTextarea),
|
|
ts: Date.now(),
|
|
});
|
|
});
|
|
};
|
|
|
|
const pickSystemLanguages = (session: Session): Array<LanguageCode> => {
|
|
const available = session.availableSpellCheckerLanguages ?? [];
|
|
const availableMap = new Map(available.map((code) => [normalizeLanguage(code), code]));
|
|
|
|
const preferred = (typeof app.getPreferredSystemLanguages === 'function' && app.getPreferredSystemLanguages()) || [
|
|
app.getLocale(),
|
|
];
|
|
|
|
const selected: Array<LanguageCode> = [];
|
|
for (const lang of preferred) {
|
|
const normalized = normalizeLanguage(lang);
|
|
const exact = availableMap.get(normalized);
|
|
if (exact) {
|
|
selected.push(exact);
|
|
}
|
|
}
|
|
|
|
if (selected.length > 0) return selected;
|
|
if (available.length > 0) return [available[0]];
|
|
return [];
|
|
};
|
|
|
|
const applyStateToSession = (session: Session, state: SpellcheckState): void => {
|
|
session.setSpellCheckerEnabled(state.enabled);
|
|
|
|
if (!isMac) {
|
|
const languages =
|
|
state.languages.length > 0
|
|
? state.languages
|
|
: pickSystemLanguages(session) || session.availableSpellCheckerLanguages;
|
|
if (languages && languages.length > 0) {
|
|
session.setSpellCheckerLanguages(languages);
|
|
}
|
|
}
|
|
};
|
|
|
|
const shouldHandleContextMenu = (webContents: WebContents, params: Electron.ContextMenuParams): boolean => {
|
|
if (!params.isEditable) return false;
|
|
|
|
const inputFieldType = (params as {inputFieldType?: string}).inputFieldType;
|
|
const isPassword =
|
|
(params as {isPassword?: boolean}).isPassword === true ||
|
|
inputFieldType === 'password' ||
|
|
(params as {formControlType?: string}).formControlType === 'password';
|
|
if (isPassword) return false;
|
|
|
|
const target = contextSourceByWebContents.get(webContents);
|
|
const targetRecent = target && Date.now() - target.ts < 5000;
|
|
const isTextLike = inputFieldType === 'plainText' || inputFieldType === 'textarea' || inputFieldType === undefined;
|
|
|
|
return Boolean((targetRecent && target.isTextarea) || isTextLike);
|
|
};
|
|
|
|
export const registerSpellcheck = (webContents: WebContents): void => {
|
|
ensureContextIpc();
|
|
|
|
const session = webContents.session;
|
|
let state: SpellcheckState = {...defaultState};
|
|
|
|
const pickLanguages = (langs: Array<string>, electronSession: Electron.Session): Array<string> => {
|
|
if (langs.length > 0) {
|
|
return langs;
|
|
}
|
|
|
|
if (!isMac) {
|
|
return pickSystemLanguages(electronSession);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const normalizeState = (incoming: RendererSpellcheckState | SpellcheckState): SpellcheckState => {
|
|
const available = session.availableSpellCheckerLanguages ?? [];
|
|
const availableSet = new Set(available.map(normalizeLanguage));
|
|
const langs = (incoming.languages ?? state.languages ?? []).filter((lang) =>
|
|
availableSet.has(normalizeLanguage(lang)),
|
|
);
|
|
const pickedLanguages = pickLanguages(langs, session);
|
|
|
|
return {
|
|
enabled: incoming.enabled ?? state.enabled ?? defaultState.enabled,
|
|
languages: pickedLanguages,
|
|
};
|
|
};
|
|
|
|
const broadcastState = () => {
|
|
webContents.send('spellcheck-state-changed', state);
|
|
};
|
|
|
|
const setState = (next: RendererSpellcheckState | SpellcheckState, opts?: {broadcast?: boolean}) => {
|
|
state = normalizeState(next);
|
|
applyStateToSession(session, state);
|
|
if (opts?.broadcast !== false) {
|
|
broadcastState();
|
|
}
|
|
};
|
|
|
|
setState(state, {broadcast: false});
|
|
|
|
ipcMain.handle('spellcheck-get-state', () => state);
|
|
ipcMain.handle('spellcheck-set-state', (_event, next: RendererSpellcheckState) => {
|
|
setState(next);
|
|
return state;
|
|
});
|
|
const openLanguageSettings = async () => {
|
|
if (!showSpellMenu) return false;
|
|
try {
|
|
if (isMac) {
|
|
await shell.openExternal('x-apple.systempreferences:com.apple.preference.keyboard');
|
|
return true;
|
|
}
|
|
if (isWindows) {
|
|
await shell.openExternal('ms-settings:regionlanguage');
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
log.warn('[Spellcheck] Failed to open language settings', error);
|
|
return false;
|
|
}
|
|
};
|
|
ipcMain.handle('spellcheck-get-available-languages', () => session.availableSpellCheckerLanguages ?? []);
|
|
ipcMain.handle('spellcheck-open-language-settings', () => openLanguageSettings());
|
|
ipcMain.handle('spellcheck-replace-misspelling', (_event, replacement: string) => {
|
|
webContents.replaceMisspelling(replacement);
|
|
});
|
|
ipcMain.handle('spellcheck-add-word-to-dictionary', (_event, word: string) => {
|
|
session.addWordToSpellCheckerDictionary(word);
|
|
});
|
|
|
|
webContents.on('context-menu', (event, params) => {
|
|
if (!shouldHandleContextMenu(webContents, params)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
const spellcheckEnabled = session.isSpellCheckerEnabled();
|
|
const misspelledWord = params.misspelledWord;
|
|
const suggestions = params.dictionarySuggestions || [];
|
|
|
|
webContents.send('textarea-context-menu', {
|
|
misspelledWord: spellcheckEnabled ? misspelledWord : undefined,
|
|
suggestions: spellcheckEnabled && misspelledWord ? suggestions : [],
|
|
editFlags: {
|
|
canUndo: params.editFlags.canUndo,
|
|
canRedo: params.editFlags.canRedo,
|
|
canCut: params.editFlags.canCut,
|
|
canCopy: params.editFlags.canCopy,
|
|
canPaste: params.editFlags.canPaste,
|
|
canSelectAll: params.editFlags.canSelectAll,
|
|
},
|
|
x: params.x,
|
|
y: params.y,
|
|
});
|
|
});
|
|
};
|