diff --git a/src/main/api/index.ts b/src/main/api/index.ts index 07502bb7..e26202d2 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -69,6 +69,11 @@ interface ShortcutLaunchContext { pastedText: string | null } +export interface GlobalShortcutPreparation { + target: string + shouldCaptureSelectedText: boolean +} + /** * API管理器 - 统一初始化和管理所有API模块 */ @@ -156,7 +161,7 @@ class APIManager { this.setupSpecialHandlers() // 设置全局快捷键处理器(需要访问多个模块) - settingsAPI.setGlobalShortcutHandler((target) => this.handleGlobalShortcut(target)) + settingsAPI.setGlobalShortcutHandler((target, context) => this.handleGlobalShortcut(target, context)) } /** @@ -253,6 +258,45 @@ class APIManager { windowAPI.resizeWindow(height) } + /** + * 预解析全局快捷键目标,判断启动前是否需要采集选中文本。 + * 仅文本类插件命令会触发复制取词,避免无关快捷键产生副作用。 + */ + public prepareGlobalShortcut(target: string): GlobalShortcutPreparation { + try { + const parts = target.split('/') + if (parts.length !== 2) { + return { target, shouldCaptureSelectedText: false } + } + + const plugins: any = databaseAPI.dbGet('plugins') + const disabledPlugins = pluginsAPI.getDisabledPluginSet() + const pluginList = Array.isArray(plugins) + ? plugins.filter((plugin: any) => !disabledPlugins.has(plugin.path)) + : [] + + const [pluginDescription, cmdName] = parts + const plugin = pluginList.find( + (p: any) => p.name === pluginDescription || p.title === pluginDescription + ) + if (!plugin) { + return { target, shouldCaptureSelectedText: false } + } + + const result = this.findCommandInPlugin(plugin, cmdName) + if (!result) { + return { target, shouldCaptureSelectedText: false } + } + + return { + target, + shouldCaptureSelectedText: result.cmdType === 'text' || result.cmdType === 'over' || result.cmdType === 'regex' + } + } catch { + return { target, shouldCaptureSelectedText: false } + } + } + /** * 在指定插件中查找匹配的命令 */ diff --git a/src/main/api/renderer/settings.ts b/src/main/api/renderer/settings.ts index 74573ad3..0d07ad73 100644 --- a/src/main/api/renderer/settings.ts +++ b/src/main/api/renderer/settings.ts @@ -1,12 +1,42 @@ import { app, globalShortcut, ipcMain, nativeTheme } from 'electron' import type { PluginManager } from '../../managers/pluginManager' +import clipboardManager from '../../managers/clipboardManager.js' + +// 共享API(主程序和插件都能用) +import { WindowManager as NativeWindowManager } from '../../core/native/index.js' import { getCurrentShortcut, updateShortcut } from '../../index.js' import doubleTapManager from '../../core/doubleTapManager.js' import proxyManager from '../../managers/proxyManager.js' import windowManager from '../../managers/windowManager.js' +import type { GlobalShortcutPreparation } from '../index' +import api from '../index' import databaseAPI from '../shared/database' +const GLOBAL_SHORTCUT_COOLDOWN_MS = 180 +const KEY_RELEASE_WAIT_TIMEOUT_MS = 1000 +const CLIPBOARD_COPY_WAIT_TIMEOUT_MS = 1500 + +/** + * 快捷键触发时携带的文件输入 + */ +interface ShortcutInputFile { + path: string + name: string + isDirectory: boolean + isFile?: boolean +} + +/** + * 快捷键触发启动链路时使用的输入上下文 + */ +interface ShortcutLaunchContext { + searchQuery: string + pastedImage: string | null + pastedFiles: ShortcutInputFile[] | null + pastedText: string | null +} + /** * 设置管理API - 主程序专用 * 包含主题、快捷键、开机启动等设置 @@ -24,6 +54,8 @@ export class SettingsAPI { // 临时快捷键录制相关 private recordingShortcuts: string[] = [] + private lastGlobalShortcutTriggeredAt = new Map() + private globalShortcutKeyboardStateReleasers = new Map void>() private setupIPC(): void { // 主题 @@ -216,15 +248,21 @@ export class SettingsAPI { return shortcut.split('+')[0] } - // 注册全局快捷键 + /** + * 注册全局快捷键。 + * 触发时会按需采集当前外部应用中的选中文本,再把上下文交给上层统一处理。 + */ public registerGlobalShortcut(shortcut: string, target: string): any { try { + this.ensureGlobalShortcutKeyboardState(shortcut) + const preparation = api.prepareGlobalShortcut(target) + if (this.isDoubleTapShortcut(shortcut)) { const modifier = this.getDoubleTapModifier(shortcut) doubleTapManager.unregister(modifier) doubleTapManager.register(modifier, () => { console.log(`双击修饰键触发: ${shortcut} -> ${target}`) - this.handleGlobalShortcut(target) + void this.triggerGlobalShortcut(preparation) }) console.log(`成功注册双击修饰键快捷键: ${shortcut} -> ${target}`) return { success: true } @@ -235,16 +273,18 @@ export class SettingsAPI { const success = globalShortcut.register(shortcut, () => { console.log(`全局快捷键触发: ${shortcut} -> ${target}`) - this.handleGlobalShortcut(target) + void this.triggerGlobalShortcut(preparation) }) if (!success) { + this.releaseGlobalShortcutKeyboardState(shortcut) return { success: false, error: '快捷键注册失败,可能已被其他应用占用' } } console.log(`成功注册全局快捷键: ${shortcut} -> ${target}`) return { success: true } } catch (error: unknown) { + this.releaseGlobalShortcutKeyboardState(shortcut) console.error('[Settings] 注册全局快捷键失败:', error) return { success: false, error: error instanceof Error ? error.message : '未知错误' } } @@ -253,6 +293,8 @@ export class SettingsAPI { // 注销全局快捷键 public unregisterGlobalShortcut(shortcut: string): any { try { + this.releaseGlobalShortcutKeyboardState(shortcut) + if (this.isDoubleTapShortcut(shortcut)) { const modifier = this.getDoubleTapModifier(shortcut) doubleTapManager.unregister(modifier) @@ -269,19 +311,126 @@ export class SettingsAPI { } } - // 处理全局快捷键触发 - // 注意:实际的启动逻辑在 APIManager 中处理,这里只是触发回调 - private handleGlobalShortcut(target: string): void { - // 调用外部设置的回调 + /** + * 为已注册的全局快捷键持有键盘状态监听。 + * 这样触发时可以直接读取完整的按键释放状态,不必临时启动监听。 + */ + private ensureGlobalShortcutKeyboardState(shortcut: string): void { + this.releaseGlobalShortcutKeyboardState(shortcut) + this.globalShortcutKeyboardStateReleasers.set(shortcut, doubleTapManager.acquireKeyboardState()) + } + + /** + * 释放某个全局快捷键持有的键盘状态监听引用。 + */ + private releaseGlobalShortcutKeyboardState(shortcut: string): void { + const release = this.globalShortcutKeyboardStateReleasers.get(shortcut) + if (!release) { + return + } + + release() + this.globalShortcutKeyboardStateReleasers.delete(shortcut) + } + + /** + * 处理全局快捷键的统一触发入口。 + * 仅在目标命令需要文本上下文时才会执行复制取词,避免无关快捷键产生副作用。 + */ + private async triggerGlobalShortcut(preparation: GlobalShortcutPreparation): Promise { + if (!this.shouldTriggerGlobalShortcut(preparation.target)) { + return + } + + const context = preparation.shouldCaptureSelectedText + ? await this.captureSelectedTextContext() + : undefined + await this.handleGlobalShortcut(preparation.target, context) + } + + /** + * 判断某个快捷键目标是否允许在阻断期内再次触发。 + * 同一 target 在 180ms 内只会放行一次。 + */ + private shouldTriggerGlobalShortcut(target: string): boolean { + const now = Date.now() + const lastTriggeredAt = this.lastGlobalShortcutTriggeredAt.get(target) ?? 0 + if (now - lastTriggeredAt < GLOBAL_SHORTCUT_COOLDOWN_MS) { + return false + } + + this.lastGlobalShortcutTriggeredAt.set(target, now) + for (const [cachedTarget, timestamp] of this.lastGlobalShortcutTriggeredAt.entries()) { + if (now - timestamp >= GLOBAL_SHORTCUT_COOLDOWN_MS) { + this.lastGlobalShortcutTriggeredAt.delete(cachedTarget) + } + } + return true + } + + /** + * 获取当前选中文本并转换成快捷键启动上下文。 + * 会等待触发快捷键的按键全部弹起后再执行复制,避免修饰键残留改变复制组合键。 + */ + private async captureSelectedTextContext(): Promise { + try { + await doubleTapManager.waitForAllKeysReleased(KEY_RELEASE_WAIT_TIMEOUT_MS) + + const lastSequence = clipboardManager.getLastCopiedSequence() + const modifier = process.platform === 'darwin' ? 'meta' : 'ctrl' + NativeWindowManager.simulateKeyboardTap('c', modifier) + + const lastCopiedContent = await clipboardManager.waitForNextCopiedContent( + lastSequence, + CLIPBOARD_COPY_WAIT_TIMEOUT_MS + ) + + if (lastCopiedContent?.type === 'text' && typeof lastCopiedContent.data === 'string') { + const text = lastCopiedContent.data + if (text.trim()) { + return { + searchQuery: text, + pastedImage: null, + pastedFiles: null, + pastedText: text + } + } + } + } catch (error) { + console.error('[Settings] 获取选中文本失败:', error) + } + + return { + searchQuery: '', + pastedImage: null, + pastedFiles: null, + pastedText: null + } + } + + /** + * 处理全局快捷键触发。 + * 兼容普通全局快捷键和双击修饰键快捷键,统一向上层传递目标与上下文。 + */ + private async handleGlobalShortcut( + target: string, + context?: ShortcutLaunchContext + ): Promise { if (this.onGlobalShortcutTriggered) { - this.onGlobalShortcutTriggered(target) + await this.onGlobalShortcutTriggered(target, context) } } // 外部回调(由 APIManager 设置) - private onGlobalShortcutTriggered?: (target: string) => void - - public setGlobalShortcutHandler(handler: (target: string) => void): void { + private onGlobalShortcutTriggered?: (target: string, context?: ShortcutLaunchContext) => void | Promise + + /** + * 设置全局快捷键触发后的统一回调。 + * 上层可根据目标命令和上下文完成最终启动。 + */ + public setGlobalShortcutHandler( + handler: (target: string, context?: ShortcutLaunchContext) => void | Promise + ): void { this.onGlobalShortcutTriggered = handler } diff --git a/src/main/core/doubleTapManager.ts b/src/main/core/doubleTapManager.ts index 5a1bacbf..a471616a 100644 --- a/src/main/core/doubleTapManager.ts +++ b/src/main/core/doubleTapManager.ts @@ -32,6 +32,9 @@ class DoubleTapManager { private nonModifierPressed = false private started = false private listenersRegistered = false + private pressedKeycodes = new Set() + private allKeysReleasedWaiters = new Set<() => void>() + private keepAliveCount = 0 // 双击最大间隔(毫秒) private readonly DOUBLE_TAP_INTERVAL = 400 @@ -55,9 +58,7 @@ class DoubleTapManager { unregister(modifier: string): void { const normalized = normalizeModifier(modifier) this.handlers = this.handlers.filter((h) => h.modifier !== normalized) - if (this.handlers.length === 0) { - this.stop() - } + this.maybeStop() } /** @@ -65,14 +66,48 @@ class DoubleTapManager { */ unregisterAll(): void { this.handlers = [] - this.stop() + this.maybeStop() } /** - * 检查是否有指定修饰键的已注册回调 + * 临时保持全局键盘监听开启。 + * 用于需要感知按键释放时机但并未注册双击回调的场景。 */ - has(modifier: string): boolean { - return this.handlers.some((h) => h.modifier === normalizeModifier(modifier)) + acquireKeyboardState(): () => void { + this.keepAliveCount += 1 + this.ensureStarted() + + return () => { + this.keepAliveCount = Math.max(0, this.keepAliveCount - 1) + this.maybeStop() + } + } + + /** + * 等待当前所有按下的按键全部释放。 + * 若系统丢失了 keyup 事件,会在超时后继续,避免调用方永久挂起。 + */ + waitForAllKeysReleased(timeoutMs: number = 1000): Promise { + if (this.pressedKeycodes.size === 0) { + return Promise.resolve() + } + + return new Promise((resolve) => { + let timer: ReturnType | null = setTimeout(() => { + this.allKeysReleasedWaiters.delete(wrappedResolve) + resolve() + }, timeoutMs) + + const wrappedResolve = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + resolve() + } + + this.allKeysReleasedWaiters.add(wrappedResolve) + }) } private ensureStarted(): void { @@ -106,9 +141,20 @@ class DoubleTapManager { this.started = false this.lastModifierUp = null this.nonModifierPressed = false + this.modifierDownTime = 0 + this.pressedKeycodes.clear() + this.resolveAllKeysReleasedWaiters() + } + + private maybeStop(): void { + if (this.handlers.length === 0 && this.keepAliveCount === 0) { + this.stop() + } } private handleKeyDown(e: { keycode: number }): void { + this.pressedKeycodes.add(e.keycode) + const modifier = MODIFIER_KEYCODES[e.keycode] if (modifier) { if (this.modifierDownTime === 0) { @@ -122,6 +168,11 @@ class DoubleTapManager { } private handleKeyUp(e: { keycode: number }): void { + this.pressedKeycodes.delete(e.keycode) + if (this.pressedKeycodes.size === 0) { + this.resolveAllKeysReleasedWaiters() + } + const modifier = MODIFIER_KEYCODES[e.keycode] if (!modifier) { this.nonModifierPressed = false @@ -161,6 +212,17 @@ class DoubleTapManager { this.lastModifierUp = { modifier, time: now } } + private resolveAllKeysReleasedWaiters(): void { + if (this.allKeysReleasedWaiters.size === 0) { + return + } + + for (const resolve of this.allKeysReleasedWaiters) { + resolve() + } + this.allKeysReleasedWaiters.clear() + } + private fireHandlers(modifier: string): void { for (const handler of this.handlers) { if (handler.modifier === modifier) { diff --git a/src/main/managers/clipboardManager.ts b/src/main/managers/clipboardManager.ts index 4b72ce4a..cabd05fc 100644 --- a/src/main/managers/clipboardManager.ts +++ b/src/main/managers/clipboardManager.ts @@ -96,6 +96,7 @@ class ClipboardManager { // 记录最后一次复制的内容(统一管理) private lastCopiedContent: LastCopiedContent | null = null private lastCopiedSequence = 0 + private lastCopiedSequenceWaiters = new Map void>>() // 临时取消剪贴板监听的计时器(防止 paste API 写入剪贴板时自我触发) private cancelWatchTimeout: ReturnType | null = null @@ -235,7 +236,6 @@ class ClipboardManager { } if (item) { - // console.log('[Clipboard] 新剪贴板内容:', item) await this.saveItem(item as ClipboardItem) // 通知插件剪贴板变化 pluginManager?.sendPluginMessage('clipboard-change', item) @@ -267,6 +267,7 @@ class ClipboardManager { timestamp: Date.now(), sequence: ++this.lastCopiedSequence } + this.resolveLastCopiedSequenceWaiters(this.lastCopiedContent) // 生成 hash(基于所有文件路径) const hashContent = files.map((f) => f.path).join('|') @@ -314,6 +315,7 @@ class ClipboardManager { timestamp: Date.now(), sequence: ++this.lastCopiedSequence } + this.resolveLastCopiedSequenceWaiters(this.lastCopiedContent) // 检查图片大小 if (buffer.length > this.config.maxImageSize) { @@ -373,6 +375,7 @@ class ClipboardManager { timestamp: Date.now(), sequence: ++this.lastCopiedSequence } + this.resolveLastCopiedSequenceWaiters(this.lastCopiedContent) return { id: uuidv4(), @@ -384,6 +387,19 @@ class ClipboardManager { } } + private resolveLastCopiedSequenceWaiters(content: LastCopiedContent): void { + for (const [minSequence, waiters] of this.lastCopiedSequenceWaiters.entries()) { + if (content.sequence <= minSequence) { + continue + } + + for (const resolve of waiters) { + resolve(content) + } + this.lastCopiedSequenceWaiters.delete(minSequence) + } + } + // 保存记录 private async saveItem(item: ClipboardItem): Promise { try { @@ -802,6 +818,42 @@ class ClipboardManager { return this.lastCopiedContent?.sequence ?? 0 } + /** + * 等待下一次晚于指定序号的复制内容。 + * 若复制动作没有真正写入剪贴板,会在超时后返回 null,避免快捷键链路永久挂起。 + */ + public waitForNextCopiedContent( + minSequence: number, + timeoutMs: number = 1500 + ): Promise { + const latestContent = this.lastCopiedContent + if (latestContent && latestContent.sequence > minSequence) { + return Promise.resolve(latestContent) + } + + return new Promise((resolve) => { + const waiters = this.lastCopiedSequenceWaiters.get(minSequence) ?? new Set() + let timer: ReturnType | null = setTimeout(() => { + waiters.delete(wrappedResolve) + if (waiters.size === 0) { + this.lastCopiedSequenceWaiters.delete(minSequence) + } + resolve(null) + }, timeoutMs) + + const wrappedResolve = (content: LastCopiedContent) => { + if (timer) { + clearTimeout(timer) + timer = null + } + resolve(content) + } + + waiters.add(wrappedResolve) + this.lastCopiedSequenceWaiters.set(minSequence, waiters) + }) + } + // 获取最后一次复制的文本(在指定时间内)- 兼容旧 API public async getLastCopiedText(timeLimit: number): Promise { const content = await this.getLastCopiedContent(timeLimit)