Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 66 additions & 51 deletions src/main/api/renderer/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, globalShortcut, ipcMain, nativeTheme } from 'electron'
import fs from 'fs'
import type { PluginManager } from '../../managers/pluginManager'
Comment thread
Particaly marked this conversation as resolved.
import clipboardManager from '../../managers/clipboardManager.js'

// 共享API(主程序和插件都能用)
import { WindowManager as NativeWindowManager } from '../../core/native/index.js'
Expand All @@ -13,10 +13,6 @@ 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 = 180
const CLIPBOARD_COPY_WAIT_TIMEOUT_MS = 180

/**
* 快捷键触发时携带的文件输入
*/
Expand Down Expand Up @@ -54,10 +50,9 @@ export class SettingsAPI {

// 临时快捷键录制相关
private recordingShortcuts: string[] = []
private lastGlobalShortcutTriggeredAt = new Map<string, number>()
private globalShortcutKeyboardStateReleasers = new Map<string, () => void>()
// 全局快捷键配置映射(存储每个快捷键的 autoCopy 等配置)
private globalShortcutConfigs: Map<string, { autoCopy: boolean }> = new Map()
private globalShortcutKeyboardStateReleasers = new Map<string, () => void>()

private setupIPC(): void {
// 主题
Expand Down Expand Up @@ -406,52 +401,38 @@ export class SettingsAPI {

/**
* 判断某个快捷键目标是否允许在阻断期内再次触发。
* 同一 target 在 180ms 内只会放行一次
* 由于新的 native getSelectedContent() 方法不再需要等待,防抖逻辑已移除
*/
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)
}
}
private shouldTriggerGlobalShortcut(_target: string): boolean {
return true
}

/**
* 获取当前选中文本并转换成快捷键启动上下文
* 会等待触发快捷键的按键全部弹起后再执行复制,避免修饰键残留改变复制组合键
* 获取当前选中内容并转换成快捷键启动上下文
* 使用 native getSelectedContent() 方法,自动处理按键释放和剪贴板暂停
*/
private async captureSelectedTextContext(): Promise<ShortcutLaunchContext> {
console.log('[Settings] 开始捕获选中文本...')
console.log('[Settings] 开始捕获选中内容...')
try {
console.log('[Settings] 等待按键释放...')
await doubleTapManager.waitForAllKeysReleased(KEY_RELEASE_WAIT_TIMEOUT_MS)
console.log('[Settings] 按键已释放')

const lastSequence = clipboardManager.getLastCopiedSequence()
console.log('[Settings] 上次剪贴板序列号:', lastSequence)

const modifier = process.platform === 'darwin' ? 'meta' : 'ctrl'
console.log('[Settings] 模拟按键: Ctrl/Cmd+C')
NativeWindowManager.simulateKeyboardTap('c', modifier)

console.log('[Settings] 等待剪贴板更新,超时时间:', CLIPBOARD_COPY_WAIT_TIMEOUT_MS, 'ms')
const lastCopiedContent = await clipboardManager.waitForNextCopiedContent(
lastSequence,
CLIPBOARD_COPY_WAIT_TIMEOUT_MS
)
const contents = NativeWindowManager.getSelectedContent()

// 防御性检查:确保 contents 是有效数组
if (!Array.isArray(contents)) {
console.log('[Settings] 未捕获到任何内容 (contents 不是数组)')
return {
searchQuery: '',
pastedImage: null,
pastedFiles: null,
pastedText: null
}
}

console.log('[Settings] 剪贴板内容:', lastCopiedContent)
console.log('[Settings] 捕获到内容数量:', contents.length)
Comment thread
Particaly marked this conversation as resolved.

if (lastCopiedContent?.type === 'text' && typeof lastCopiedContent.data === 'string') {
const text = lastCopiedContent.data
// 处理文本内容
const textContent = contents.find((item) => item.type === 'text')
if (textContent && textContent.type === 'text') {
const text = textContent.data
console.log('[Settings] 捕获到文本,长度:', text.length)
if (text.trim()) {
console.log('[Settings] 文本捕获成功')
Expand All @@ -464,16 +445,50 @@ export class SettingsAPI {
} else {
console.log('[Settings] 文本为空')
}
} else {
console.log('[Settings] 未捕获到文本内容')
}
} catch (error) {
console.error('[Settings] 获取选中文本失败:', error)
if (error instanceof Error && error.message.includes('timeout')) {
console.error(
'[Settings] 等待复制超时!可能原因:1) 没有选中文本 2) 应用不支持复制 3) 网络延迟'
)

// 处理图片内容
const imageContent = contents.find((item) => item.type === 'image')
if (imageContent && imageContent.type === 'image') {
console.log('[Settings] 捕获到图片')
return {
searchQuery: '',
pastedImage: imageContent.data,
pastedFiles: null,
pastedText: null
}
}

// 处理文件内容
const fileContent = contents.find((item) => item.type === 'file')
if (fileContent && fileContent.type === 'file') {
console.log('[Settings] 捕获到文件,数量:', fileContent.data.length)
const files = fileContent.data.map((filePath) => {
let isDirectory = false
try {
isDirectory = fs.statSync(filePath).isDirectory()
} catch (e) {
// 忽略读取失败的情况,默认设为 false
console.warn(`[Settings] 无法读取文件状态: ${filePath}`, e)
}
return {
path: filePath,
name: filePath.split(/[/\\]/).pop() || '',
isDirectory,
isFile: !isDirectory
}
})
return {
searchQuery: '',
pastedImage: null,
pastedFiles: files,
pastedText: null
}
}

console.log('[Settings] 未捕获到任何内容')
} catch (error) {
console.error('[Settings] 获取选中内容失败:', error)
}

return {
Expand Down
55 changes: 55 additions & 0 deletions src/main/core/native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ interface NativeAddon {
hwnd: number,
callback: (url: string | null) => void
) => void
/**
* 获取当前选中的内容(支持文本、文件、图像)
* 实现方式:
* - Windows: 优先使用 UI Automation API,回退到剪贴板方法
* - macOS: 使用模拟复制方法(Cmd+C)
* 自动暂停 clipboardMonitor,防止误触发监听
*/
getSelectedContent: () => Array<
| { type: 'text'; data: string }
| { type: 'file'; data: string[] }
| { type: 'image'; data: string }
>
}

interface WindowInfo {
Expand Down Expand Up @@ -526,6 +538,49 @@ export class WindowManager {
}
return (addon as NativeAddon).simulateMouseRightClick(x, y)
}

/**
* 获取当前选中的内容(支持文本、文件、图像)
*
* 实现方式:
* - Windows: 优先使用 UI Automation API,回退到剪贴板方法(适用于 Cursor/VS Code 等编辑器)
* - macOS: 使用模拟复制方法(Cmd+C)
*
* 在模拟复制时会自动暂停内部的 clipboardMonitor,防止误触发监听自身发起的事件
*
* @returns {Array<{type: string, data: any}>} 选中内容数组
* - type: 'text' | 'file' | 'image'
* - data: 根据类型不同:
* - text: 字符串
* - file: 文件路径字符串数组
* - image: base64 编码的 PNG 图像(带 format 和 encoding 字段)
*
* @example
* const contents = WindowManager.getSelectedContent();
* contents.forEach(item => {
* switch (item.type) {
* case 'text':
* console.log('Selected text:', item.data);
* break;
* case 'file':
* console.log('Selected files:', item.data);
* break;
* case 'image':
* console.log('Selected image (base64):', item.data.substring(0, 50) + '...');
* break;
* }
* });
*/
static getSelectedContent(): Array<
| { type: 'text'; data: string }
| { type: 'file'; data: string[] }
| { type: 'image'; data: string }
> {
if (platform === 'linux') {
return []
}
return (addon as NativeAddon).getSelectedContent()
}
}

/**
Expand Down