Files
vim-im-switch/main.ts
2025-11-11 18:14:34 +08:00

396 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Plugin, PluginSettingTab, Setting, MarkdownView, TFile, WorkspaceLeaf } from 'obsidian';
import { exec } from "child_process";
import { promisify } from "util";
import * as CodeMirror from 'codemirror';
interface VimIMSwitchSettings {
fcitxRemotePath_macOS: string;
fcitxRemotePath_windows: string;
fcitxRemotePath_linux: string;
englishInputMethod: string;
chineseInputMethod: string;
}
const DEFAULT_SETTINGS: VimIMSwitchSettings = {
fcitxRemotePath_macOS: '/usr/local/bin/fcitx-remote',
fcitxRemotePath_windows: 'C:\\Program Files\\bin\\fcitx-remote',
fcitxRemotePath_linux: '/usr/bin/fcitx-remote',
englishInputMethod: 'com.apple.keylayout.ABC',
chineseInputMethod: 'auto-detect', // 将自动检测当前中文输入法
}
const pexec = promisify(exec);
enum IMStatus {
None,
Activate,
Deactivate,
}
export default class VimIMSwitchPlugin extends Plugin {
settings: VimIMSwitchSettings;
imStatus = IMStatus.None;
fcitxRemotePath = "";
private editorMode: 'cm5' | 'cm6' = null;
private initialized = false;
private cmEditor: CodeMirror.Editor = null;
private lastInsertModeIMStatus = IMStatus.None; // 记住上一次insert模式的输入法状态
private keyboardListenerSetup = false; // 防止重复设置键盘监听器
private lastKeyTime = 0; // 防抖:记录上次按键时间
private currentVimMode = 'normal'; // 跟踪当前vim模式
async onload() {
console.log('🚀 Loading plugin...');
await this.loadSettings();
// 尽早设置全局键盘监听器
this.setupObsidianEditorEvents();
this.addSettingTab(new IMSwitchSettingTab(this.app, this));
this.app.workspace.on('file-open', async (file: TFile) => {
if (!this.initialized) {
await this.initialize();
}
if (this.cmEditor) {
await this.getFcitxRemoteStatus();
this.lastInsertModeIMStatus = this.imStatus;
await this.deactivateIM();
if (typeof this.cmEditor.off === 'function') {
this.cmEditor.off("vim-mode-change", this.onVimModeChange);
}
if (typeof this.cmEditor.on === 'function') {
this.cmEditor.on("vim-mode-change", this.onVimModeChange);
}
}
});
this.app.workspace.on('active-leaf-change', async (leaf: WorkspaceLeaf) => {
if(this.app.workspace.activeLeaf.view.getViewType() == "markdown") {
if (!this.initialized) {
await this.initialize();
}
if (this.cmEditor) {
await this.getFcitxRemoteStatus();
this.lastInsertModeIMStatus = this.imStatus;
await this.deactivateIM();
this.setupObsidianEditorEvents();
}
}
});
}
async initialize() {
if (this.initialized) {
return;
}
if ('editor:toggle-source' in (this.app as any).commands.editorCommands) {
this.editorMode = 'cm6';
} else {
this.editorMode = 'cm5';
}
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
if (this.editorMode == 'cm6') {
const possiblePaths = [
(view as any).sourceMode?.cmEditor?.cm?.cm,
(view as any).sourceMode?.cmEditor?.cm,
(view as any).sourceMode?.cmEditor,
(view as any).editor?.cm,
(view.editor as any)?.cm
];
for (let i = 0; i < possiblePaths.length; i++) {
const path = possiblePaths[i];
if (path && !this.cmEditor) {
this.cmEditor = path;
break;
}
}
} else {
const possiblePaths = [
(view as any).sourceMode?.cmEditor,
(view as any).editor?.cm,
(view.editor as any)?.cm
];
for (let i = 0; i < possiblePaths.length; i++) {
const path = possiblePaths[i];
if (path && !this.cmEditor) {
this.cmEditor = path;
break;
}
}
}
}
}
setupObsidianEditorEvents() {
if (this.keyboardListenerSetup) {
return;
}
const handleKeyDown = async (event: KeyboardEvent) => {
const currentTime = Date.now();
// 防抖100ms内只处理一次
if (currentTime - this.lastKeyTime < 100) {
return;
}
this.lastKeyTime = currentTime;
// 处理ESC键只在insert/replace模式下才切换输入法
if (event.key === 'Escape') {
// 只有在insert或replace模式下按ESC才需要处理输入法
if (this.currentVimMode === 'insert' || this.currentVimMode === 'replace') {
// 退出insert模式前先保存当前输入法状态
const beforeIM = await this.runCmd(this.fcitxRemotePath, ["-n"]);
const currentIMName = beforeIM.trim();
// 检查当前输入法是中文还是英文
if (currentIMName === this.settings.chineseInputMethod) {
this.lastInsertModeIMStatus = IMStatus.Activate;
console.log('ESC → English (saved Chinese)');
} else {
this.lastInsertModeIMStatus = IMStatus.Deactivate;
console.log('ESC → English (saved English)');
} // 切换到英文输入法
this.currentVimMode = 'normal';
await this.deactivateIM();
}
// 如果已经在normal模式ESC键不做任何输入法切换
}
// 处理进入insert模式的按键只在normal模式下
else if (this.currentVimMode === 'normal' &&
['i', 'I', 'a', 'A', 'o', 'O', 's', 'S', 'c', 'C'].includes(event.key) &&
!event.ctrlKey && !event.metaKey && !event.altKey) {
// 延迟一下让Vim先切换模式
setTimeout(async () => {
this.currentVimMode = 'insert';
// 恢复上次的输入法状态
if (this.lastInsertModeIMStatus == IMStatus.Activate) {
console.log("→ Chinese");
await this.activateIM();
} else {
console.log("→ English");
await this.deactivateIM();
}
}, 10);
}
};
// 移除旧的监听器
if ((this as any).obsidianKeyDownListener) {
document.removeEventListener('keydown', (this as any).obsidianKeyDownListener, { capture: true });
}
// 使用capture模式确保更早接收事件
(this as any).obsidianKeyDownListener = handleKeyDown;
document.addEventListener('keydown', handleKeyDown, { capture: true });
this.keyboardListenerSetup = true;
}
onVimModeChange = async (cm: any) => {
// 防止短时间内重复处理相同的模式切换
const currentTime = Date.now();
if (cm.mode === this.currentVimMode && currentTime - this.lastKeyTime < 100) {
return;
}
// 更新当前vim模式状态
this.currentVimMode = cm.mode;
if (cm.mode == "normal" || cm.mode == "visual") {
// 进入normal/visual模式前先保存当前输入法状态
await this.getFcitxRemoteStatus();
if (this.imStatus == IMStatus.Activate) {
this.lastInsertModeIMStatus = IMStatus.Activate;
}
console.log("→ English");
await this.deactivateIM();
} else if (cm.mode == "insert" || cm.mode == "replace") {
// 进入insert模式时恢复上次的输入法状态
if (this.lastInsertModeIMStatus == IMStatus.Activate) {
console.log("→ Chinese");
await this.activateIM();
} else {
console.log("→ English");
await this.deactivateIM();
}
}
}
async runCmd(cmd: string, args: string[] = []) : Promise<string>{
const output = await pexec(`${cmd} ${args.join(" ")}`);
return output.stdout;
}
async getFcitxRemoteStatus() {
if (this.fcitxRemotePath == "") {
console.log("❌ Cannot get fcitx-remote path, please set it correctly.");
return;
}
try {
let fcitxRemoteOutput = await this.runCmd(this.fcitxRemotePath);
fcitxRemoteOutput = fcitxRemoteOutput.trimRight();
if (fcitxRemoteOutput == "1") {
this.imStatus = IMStatus.Deactivate;
} else if (fcitxRemoteOutput == "2") {
this.imStatus = IMStatus.Activate;
} else {
this.imStatus = IMStatus.None;
}
} catch (error) {
console.log(`❌ Error getting IM status:`, error);
}
}
async activateIM() {
if (this.fcitxRemotePath == "") {
console.log("❌ Cannot get fcitx-remote path, please set it correctly.");
return;
}
try {
await this.runCmd(this.fcitxRemotePath, ["-s", this.settings.chineseInputMethod]);
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
console.log("❌ Error activating IM:", error);
}
}
async deactivateIM() {
if (this.fcitxRemotePath == "") {
console.log("❌ Cannot get fcitx-remote path, please set it correctly.");
return;
}
try {
await this.runCmd(this.fcitxRemotePath, ["-s", this.settings.englishInputMethod]);
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
console.log("❌ Error deactivating IM:", error);
}
}
onunload() {
// 清理 CodeMirror 事件监听器
if (this.cmEditor && typeof this.cmEditor.off === 'function') {
this.cmEditor.off("vim-mode-change", this.onVimModeChange);
}
// 清理键盘事件监听器
if ((this as any).obsidianKeyDownListener) {
document.removeEventListener('keydown', (this as any).obsidianKeyDownListener, { capture: true });
this.keyboardListenerSetup = false;
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
await this.updateCurrentPath();
await this.detectInputMethods();
}
async detectInputMethods() {
if (this.settings.chineseInputMethod === 'auto-detect') {
try {
const currentIM = await this.runCmd(this.fcitxRemotePath, ["-n"]);
const currentName = currentIM.trim();
if (currentName.includes('pinyin') ||
currentName.includes('chinese') ||
currentName.includes('tencent') ||
currentName.includes('sogou') ||
currentName.includes('baidu')) {
this.settings.chineseInputMethod = currentName;
} else {
this.settings.chineseInputMethod = 'com.tencent.inputmethod.wetype.pinyin';
}
} catch (error) {
this.settings.chineseInputMethod = 'com.tencent.inputmethod.wetype.pinyin';
}
}
}
async updateCurrentPath() {
console.log(`🖥️ Platform detected: ${process.platform}`);
switch (process.platform) {
case 'darwin':
this.fcitxRemotePath = this.settings.fcitxRemotePath_macOS;
console.log(`🍎 Using macOS path: ${this.fcitxRemotePath}`);
break;
case 'linux':
this.fcitxRemotePath = this.settings.fcitxRemotePath_linux;
console.log(`🐧 Using Linux path: ${this.fcitxRemotePath}`);
break;
case 'win32':
this.fcitxRemotePath = this.settings.fcitxRemotePath_windows;
console.log(`🪟 Using Windows path: ${this.fcitxRemotePath}`);
break;
default:
console.log(`❌ Platform ${process.platform} is not supported currently.`);
break;
}
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class IMSwitchSettingTab extends PluginSettingTab {
plugin: VimIMSwitchPlugin;
constructor(app: App, plugin: VimIMSwitchPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
let {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'Settings for Vim IM Switch plugin.'});
new Setting(containerEl)
.setName('Fcitx Remote Path for macOS')
.setDesc('The absolute path to fcitx-remote bin file on macOS.')
.addText(text => text
.setPlaceholder(DEFAULT_SETTINGS.fcitxRemotePath_macOS)
.setValue(this.plugin.settings.fcitxRemotePath_macOS)
.onChange(async (value) => {
this.plugin.settings.fcitxRemotePath_macOS = value;
this.plugin.updateCurrentPath();
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Fcitx Remote Path for Linux')
.setDesc('The absolute path to fcitx-remote bin file on Linux.')
.addText(text => text
.setPlaceholder(DEFAULT_SETTINGS.fcitxRemotePath_linux)
.setValue(this.plugin.settings.fcitxRemotePath_linux)
.onChange(async (value) => {
this.plugin.settings.fcitxRemotePath_linux = value;
this.plugin.updateCurrentPath();
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Fcitx Remote Path for Windows')
.setDesc('The absolute path to fcitx-remote bin file on Windows.')
.addText(text => text
.setPlaceholder(DEFAULT_SETTINGS.fcitxRemotePath_windows)
.setValue(this.plugin.settings.fcitxRemotePath_windows)
.onChange(async (value) => {
this.plugin.settings.fcitxRemotePath_windows = value;
this.plugin.updateCurrentPath();
await this.plugin.saveSettings();
}));
}
}