396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
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();
|
||
}));
|
||
}
|
||
}
|