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{ 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(); })); } }