From f960ce3038bb81e4c72f3cfcd9ebc2889c5b73a4 Mon Sep 17 00:00:00 2001 From: pantao <980141374@qq.com> Date: Fri, 22 May 2026 15:10:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(setting):=20=E4=BF=AE=E5=A4=8D=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E5=BF=AB=E6=8D=B7=E9=94=AE=E5=8F=96=E8=AF=8D=E6=97=B6?= =?UTF-8?q?=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 等待全局快捷键按键释放后再执行复制取词,并在快捷键注册期间保持键盘状态监听,避免修饰键残留导致取词异常或卡住。 --- src/main/api/index.ts | 2 +- src/main/api/renderer/settings.ts | 161 ++++++++++++++++++++++++-- src/main/core/doubleTapManager.ts | 63 ++++++++-- src/main/managers/clipboardManager.ts | 37 +++++- 4 files changed, 243 insertions(+), 20 deletions(-) diff --git a/src/main/api/index.ts b/src/main/api/index.ts index 07502bb7..6c247952 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -156,7 +156,7 @@ class APIManager { this.setupSpecialHandlers() // 设置全局快捷键处理器(需要访问多个模块) - settingsAPI.setGlobalShortcutHandler((target) => this.handleGlobalShortcut(target)) + settingsAPI.setGlobalShortcutHandler((target, context) => this.handleGlobalShortcut(target, context)) } /** diff --git a/src/main/api/renderer/settings.ts b/src/main/api/renderer/settings.ts index 74573ad3..28746802 100644 --- a/src/main/api/renderer/settings.ts +++ b/src/main/api/renderer/settings.ts @@ -1,5 +1,9 @@ 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' @@ -7,6 +11,28 @@ import proxyManager from '../../managers/proxyManager.js' import windowManager from '../../managers/windowManager.js' import databaseAPI from '../shared/database' +const GLOBAL_SHORTCUT_COOLDOWN_MS = 180 + +/** + * 快捷键触发时携带的文件输入 + */ +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 +50,8 @@ export class SettingsAPI { // 临时快捷键录制相关 private recordingShortcuts: string[] = [] + private lastGlobalShortcutTriggeredAt = new Map() + private globalShortcutKeyboardStateReleasers = new Map void>() private setupIPC(): void { // 主题 @@ -216,15 +244,20 @@ export class SettingsAPI { return shortcut.split('+')[0] } - // 注册全局快捷键 + /** + * 注册全局快捷键。 + * 触发时会先异步采集当前外部应用中的选中文本,再把上下文交给上层统一处理。 + */ public registerGlobalShortcut(shortcut: string, target: string): any { try { + this.ensureGlobalShortcutKeyboardState(shortcut) + 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(target) }) console.log(`成功注册双击修饰键快捷键: ${shortcut} -> ${target}`) return { success: true } @@ -235,16 +268,18 @@ export class SettingsAPI { const success = globalShortcut.register(shortcut, () => { console.log(`全局快捷键触发: ${shortcut} -> ${target}`) - this.handleGlobalShortcut(target) + void this.triggerGlobalShortcut(target) }) 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 +288,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 +306,121 @@ 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(target: string): Promise { + if (!this.shouldTriggerGlobalShortcut(target)) { + return + } + + const context = await this.captureSelectedTextContext() + await this.handleGlobalShortcut(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() + + const lastSequence = clipboardManager.getLastCopiedSequence() + const modifier = process.platform === 'darwin' ? 'meta' : 'ctrl' + NativeWindowManager.simulateKeyboardTap('c', modifier) + + const lastCopiedContent = await clipboardManager.waitForNextCopiedContent(lastSequence) + + 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..eb0f371e 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,35 @@ 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() + } + } + + /** + * 等待当前所有按下的按键全部释放。 + * 若当前没有按键处于按下状态,则立即返回。 + */ + waitForAllKeysReleased(): Promise { + if (this.pressedKeycodes.size === 0) { + return Promise.resolve() + } + + return new Promise((resolve) => { + this.allKeysReleasedWaiters.add(resolve) + }) } private ensureStarted(): void { @@ -106,9 +128,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 +155,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 +199,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..ecb84040 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 @@ -213,6 +214,7 @@ class ClipboardManager { // 处理剪贴板变化(原生事件已去重,直接处理) private async handleClipboardChange(): Promise { + console.log('监听到剪贴板变化'); try { let item: Partial | null = null @@ -235,7 +237,7 @@ class ClipboardManager { } if (item) { - // console.log('[Clipboard] 新剪贴板内容:', item) + console.log('[Clipboard] 新剪贴板内容:', item) await this.saveItem(item as ClipboardItem) // 通知插件剪贴板变化 pluginManager?.sendPluginMessage('clipboard-change', item) @@ -267,6 +269,7 @@ class ClipboardManager { timestamp: Date.now(), sequence: ++this.lastCopiedSequence } + this.resolveLastCopiedSequenceWaiters(this.lastCopiedContent) // 生成 hash(基于所有文件路径) const hashContent = files.map((f) => f.path).join('|') @@ -314,6 +317,7 @@ class ClipboardManager { timestamp: Date.now(), sequence: ++this.lastCopiedSequence } + this.resolveLastCopiedSequenceWaiters(this.lastCopiedContent) // 检查图片大小 if (buffer.length > this.config.maxImageSize) { @@ -373,6 +377,7 @@ class ClipboardManager { timestamp: Date.now(), sequence: ++this.lastCopiedSequence } + this.resolveLastCopiedSequenceWaiters(this.lastCopiedContent) return { id: uuidv4(), @@ -384,6 +389,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 +820,23 @@ class ClipboardManager { return this.lastCopiedContent?.sequence ?? 0 } + /** + * 等待下一次晚于指定序号的复制内容。 + * 适用于已经主动触发了一次复制动作、只想等待那次新内容到达的场景。 + */ + public waitForNextCopiedContent(minSequence: number): 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() + waiters.add(resolve) + this.lastCopiedSequenceWaiters.set(minSequence, waiters) + }) + } + // 获取最后一次复制的文本(在指定时间内)- 兼容旧 API public async getLastCopiedText(timeLimit: number): Promise { const content = await this.getLastCopiedContent(timeLimit) From 535b73fb8beede8bc6405fe2e73d4172714794f3 Mon Sep 17 00:00:00 2001 From: pantao <980141374@qq.com> Date: Fri, 22 May 2026 15:27:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(setting):=20=E4=BC=98=E5=8C=96=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E5=BF=AB=E6=8D=B7=E9=94=AE=E5=8F=96=E8=AF=8D=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按需采集文本上下文,并为键盘/剪贴板等待补充超时,降低无关快捷键副作用和处理链路挂起风险。 --- src/main/api/index.ts | 44 +++++++++++++++++++++++++++ src/main/api/renderer/settings.ts | 30 ++++++++++++------ src/main/core/doubleTapManager.ts | 19 ++++++++++-- src/main/managers/clipboardManager.ts | 27 +++++++++++++--- 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/src/main/api/index.ts b/src/main/api/index.ts index 6c247952..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模块 */ @@ -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 28746802..0d07ad73 100644 --- a/src/main/api/renderer/settings.ts +++ b/src/main/api/renderer/settings.ts @@ -9,9 +9,13 @@ 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 /** * 快捷键触发时携带的文件输入 @@ -246,18 +250,19 @@ export class SettingsAPI { /** * 注册全局快捷键。 - * 触发时会先异步采集当前外部应用中的选中文本,再把上下文交给上层统一处理。 + * 触发时会按需采集当前外部应用中的选中文本,再把上下文交给上层统一处理。 */ 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}`) - void this.triggerGlobalShortcut(target) + void this.triggerGlobalShortcut(preparation) }) console.log(`成功注册双击修饰键快捷键: ${shortcut} -> ${target}`) return { success: true } @@ -268,7 +273,7 @@ export class SettingsAPI { const success = globalShortcut.register(shortcut, () => { console.log(`全局快捷键触发: ${shortcut} -> ${target}`) - void this.triggerGlobalShortcut(target) + void this.triggerGlobalShortcut(preparation) }) if (!success) { @@ -330,15 +335,17 @@ export class SettingsAPI { /** * 处理全局快捷键的统一触发入口。 - * 这里会先采集外部应用的选中文本,再把带上下文的目标交给上层。 + * 仅在目标命令需要文本上下文时才会执行复制取词,避免无关快捷键产生副作用。 */ - private async triggerGlobalShortcut(target: string): Promise { - if (!this.shouldTriggerGlobalShortcut(target)) { + private async triggerGlobalShortcut(preparation: GlobalShortcutPreparation): Promise { + if (!this.shouldTriggerGlobalShortcut(preparation.target)) { return } - const context = await this.captureSelectedTextContext() - await this.handleGlobalShortcut(target, context) + const context = preparation.shouldCaptureSelectedText + ? await this.captureSelectedTextContext() + : undefined + await this.handleGlobalShortcut(preparation.target, context) } /** @@ -367,13 +374,16 @@ export class SettingsAPI { */ private async captureSelectedTextContext(): Promise { try { - await doubleTapManager.waitForAllKeysReleased() + 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) + const lastCopiedContent = await clipboardManager.waitForNextCopiedContent( + lastSequence, + CLIPBOARD_COPY_WAIT_TIMEOUT_MS + ) if (lastCopiedContent?.type === 'text' && typeof lastCopiedContent.data === 'string') { const text = lastCopiedContent.data diff --git a/src/main/core/doubleTapManager.ts b/src/main/core/doubleTapManager.ts index eb0f371e..a471616a 100644 --- a/src/main/core/doubleTapManager.ts +++ b/src/main/core/doubleTapManager.ts @@ -85,15 +85,28 @@ class DoubleTapManager { /** * 等待当前所有按下的按键全部释放。 - * 若当前没有按键处于按下状态,则立即返回。 + * 若系统丢失了 keyup 事件,会在超时后继续,避免调用方永久挂起。 */ - waitForAllKeysReleased(): Promise { + waitForAllKeysReleased(timeoutMs: number = 1000): Promise { if (this.pressedKeycodes.size === 0) { return Promise.resolve() } return new Promise((resolve) => { - this.allKeysReleasedWaiters.add(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) }) } diff --git a/src/main/managers/clipboardManager.ts b/src/main/managers/clipboardManager.ts index ecb84040..cabd05fc 100644 --- a/src/main/managers/clipboardManager.ts +++ b/src/main/managers/clipboardManager.ts @@ -214,7 +214,6 @@ class ClipboardManager { // 处理剪贴板变化(原生事件已去重,直接处理) private async handleClipboardChange(): Promise { - console.log('监听到剪贴板变化'); try { let item: Partial | null = null @@ -237,7 +236,6 @@ class ClipboardManager { } if (item) { - console.log('[Clipboard] 新剪贴板内容:', item) await this.saveItem(item as ClipboardItem) // 通知插件剪贴板变化 pluginManager?.sendPluginMessage('clipboard-change', item) @@ -822,9 +820,12 @@ class ClipboardManager { /** * 等待下一次晚于指定序号的复制内容。 - * 适用于已经主动触发了一次复制动作、只想等待那次新内容到达的场景。 + * 若复制动作没有真正写入剪贴板,会在超时后返回 null,避免快捷键链路永久挂起。 */ - public waitForNextCopiedContent(minSequence: number): Promise { + public waitForNextCopiedContent( + minSequence: number, + timeoutMs: number = 1500 + ): Promise { const latestContent = this.lastCopiedContent if (latestContent && latestContent.sequence > minSequence) { return Promise.resolve(latestContent) @@ -832,7 +833,23 @@ class ClipboardManager { return new Promise((resolve) => { const waiters = this.lastCopiedSequenceWaiters.get(minSequence) ?? new Set() - waiters.add(resolve) + 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) }) }