update
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
117
README.md
Normal file
117
README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Quick Paste
|
||||
|
||||
桌面悬浮的剪贴板工具:按分组管理常用句子,点一下就粘贴到当前正在输入的窗口。
|
||||
|
||||
写代码、做客服、写文案时总有几句话要重复敲——问候、结束语、固定回复、常用代码片段。把它做成常驻桌面的悬浮窗,分组整理好,一键贴到任意正在输入的窗口。
|
||||
|
||||
- 不抢焦点,点完继续干活
|
||||
- 不挑场景,浏览器、记事本、Office、微信、QQ、IDE 都能用
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 Node.js 18+。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # 启动
|
||||
npm run dist # 打包 NSIS 安装包到 dist/
|
||||
```
|
||||
|
||||
`npm start` 走的是 `scripts/start.js` 而不是直接 `electron`:Git Bash 等终端会把 `ELECTRON_RUN_AS_NODE=1` 注入子进程,让 Electron 退化成普通 Node.js 卡住。启动脚本会先清掉这个变量再 spawn。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 悬浮窗无边框、可拖拽;标题栏一键切换「始终置顶」
|
||||
- 分组管理:右键重命名 / 删除 / 清空,分组与短语都可拖动重排
|
||||
- 一键粘贴:点击 → 写剪贴板 → 自动 Ctrl+V
|
||||
- 收藏 / 最近 / 使用计数;「最近」列表与使用计数都能在设置面板一键清空
|
||||
- 实时模糊搜索,命中片段高亮
|
||||
- 占位符:`{date}` `{time}` `{datetime}` `{weekday}` `{clipboard}`,编辑时实时预览;`\{xxx}` 反斜杠保留字面量
|
||||
- 明暗主题、窗口透明度(60–100%)、粘贴时是否隐藏悬浮窗
|
||||
- 侧栏可折叠(`Ctrl+B`)
|
||||
- JSON 导入导出:整库替换 或按 ID 合并
|
||||
- 单实例锁
|
||||
|
||||
## 快捷键
|
||||
|
||||
弹窗打开时只响应 `Esc` / `Ctrl+Enter`;焦点在输入框时只响应 `Ctrl` / `Alt` 组合键和 `Esc`,普通字符不会触发。
|
||||
|
||||
| 类别 | 快捷键 | 作用 |
|
||||
|---|---|---|
|
||||
| 全局 | `Ctrl+Shift+V` | 唤出 / 收起悬浮窗 |
|
||||
| 列表 | `↑` / `↓` | 上下移动选中项 |
|
||||
| 列表 | `1` ~ `9` | 粘贴当前可见顺序的前 9 条 |
|
||||
| 列表 | `Enter` | 粘贴选中的短语 |
|
||||
| 列表 | `Alt+↑` / `Alt+↓` | 调整顺序 |
|
||||
| 过滤 | `g` / `f` / `r` | 切换 全部 / 收藏 / 最近 |
|
||||
| 过滤 | `/` 或 `Ctrl+F` | 聚焦搜索框 |
|
||||
| 过滤 | `Esc` | 清空搜索 / 关闭弹窗 / 隐藏窗口 |
|
||||
| 操作 | `Ctrl+N` / `Ctrl+G` | 新建短语 / 新建分组 |
|
||||
| 操作 | `Ctrl+B` | 折叠 / 展开分组栏 |
|
||||
| 操作 | `Ctrl+,` | 打开设置 |
|
||||
| 操作 | `Ctrl+Enter` | 保存短语编辑 |
|
||||
|
||||
## 占位符
|
||||
|
||||
| 标签 | 展开为 |
|
||||
|---|---|
|
||||
| `{date}` | 当前日期,如 `2026-09-11` |
|
||||
| `{time}` | 当前时间,如 `14:32` |
|
||||
| `{datetime}` | 日期 + 时间,如 `2026-09-11 14:32` |
|
||||
| `{weekday}` | 中文星期,如 `周四` |
|
||||
| `{clipboard}` | 当前剪贴板内容 |
|
||||
|
||||
含 `{clipboard}` 的短语粘贴时会先快照原剪贴板再覆盖,避免读到自身写出去的内容。
|
||||
|
||||
想教别人用占位符时,写成 `\{date}` 会保留成字面量 `{date}`,不会被展开。
|
||||
|
||||
## 数据存储
|
||||
|
||||
| 系统 | 路径 |
|
||||
|---|---|
|
||||
| Windows | `%APPDATA%\quick-paste\data.json` |
|
||||
| macOS | `~/Library/Application Support/quick-paste/data.json` |
|
||||
| Linux | `~/.config/quick-paste/data.json` |
|
||||
|
||||
设置面板 → 数据 → 「打开」一键定位。写盘采用「先写临时文件再 rename」的原子化策略,畸形 JSON 会自动尝试修复。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**粘贴后窗口消失了?**
|
||||
默认不会。走「隐藏 → 粘贴 → 重新显示」兜底有三种:识别不到目标窗口、开了「粘贴时隐藏悬浮窗」、目标窗口拒绝被拉到前台。200ms 后会自动回来,嫌慢改 `main.js` 的 `PASTE_RESTORE_DELAY_MS`。
|
||||
|
||||
**某些应用粘贴不上?**
|
||||
部分 Electron / Chrome 内核应用为安全考虑忽略 SendKeys。普通应用都没问题。
|
||||
|
||||
**`Ctrl+Shift+V` 不起作用?**
|
||||
可能被其它程序占用了。注册失败时主进程只会在终端打一行 warn,应用照常运行——改用托盘图标唤出即可。要换快捷键改 `main.js` 的 `GLOBAL_TOGGLE_SHORTCUT`。
|
||||
|
||||
**拖到副屏的窗口下次启动不见了?**
|
||||
不会丢。启动时会按窗口中心点找最近的显示器,把窗口整体夹回该显示器的工作区;副屏拔掉 / 分辨率改了也能正常恢复。
|
||||
|
||||
**怎么完全退出?**
|
||||
关闭按钮只隐藏到托盘。真正退出请右键托盘图标 → 退出。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── package.json
|
||||
├── main.js # 主进程:窗口 / 托盘 / 模拟粘贴 / 单实例锁 / IPC
|
||||
├── preload.js # contextBridge 暴露 window.api
|
||||
├── renderer/
|
||||
│ ├── index.html
|
||||
│ ├── styles.css
|
||||
│ └── renderer.js
|
||||
├── scripts/
|
||||
│ └── start.js # 清掉 ELECTRON_RUN_AS_NODE 后启动 Electron
|
||||
├── icon.ico # 托盘 / 标题栏 / 安装包共用
|
||||
├── .gitignore # 忽略 node_modules/ 与 dist/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
`npm run dist` 会用 `electron-builder` 走 NSIS 打包,产物落在 `dist/` 下(`快速粘贴 Setup x.y.z.exe` 安装包 + `win-unpacked/` 免安装目录),仓库已通过 `.gitignore` 忽略。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
907
main.js
Normal file
907
main.js
Normal file
@@ -0,0 +1,907 @@
|
||||
/**
|
||||
* 主进程:创建悬浮窗口、托盘、IPC 处理、用 Windows API 立即触发粘贴
|
||||
*/
|
||||
// 必须在 require('electron') 之前执行!
|
||||
// 某些环境(如 npm bash)会把 ELECTRON_RUN_AS_NODE=1 设进环境,
|
||||
// 这会让 Electron 退化成普通 Node.js,无法访问 electron API。
|
||||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Tray,
|
||||
Menu,
|
||||
ipcMain,
|
||||
clipboard,
|
||||
dialog,
|
||||
screen,
|
||||
nativeImage,
|
||||
shell,
|
||||
globalShortcut,
|
||||
} = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// 必须发生在 app.whenReady 之前:通过开关抑制一些无害但刷屏的 Chromium 警告
|
||||
// (GPU 进程退出、Network service 重启等)。
|
||||
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache');
|
||||
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
|
||||
|
||||
// ==================== 单实例锁 ====================
|
||||
// 本应用「关闭窗口 = 隐藏到托盘」,用户很容易忘记它还在后台跑,
|
||||
// 于是又去双击一次桌面快捷方式。没有这把锁的话会起第二个实例,后果是:
|
||||
// 1. 托盘里出现两个图标,点哪个都只控制其中一半;
|
||||
// 2. 两个实例各自持有整份数据的内存副本,都会往同一个 data.json 写 ——
|
||||
// 后保存的那个会把先保存的改动整段覆盖掉,用户新加的短语凭空消失;
|
||||
// 3. Ctrl+Shift+V 全局快捷键只有先启动的那个能注册成功。
|
||||
// 拿不到锁就直接退出,并让已经在跑的那个实例把窗口亮出来。
|
||||
//
|
||||
// 注意:CommonJS 模块被包在函数里,这里的顶层 return 在 CJS 下也是合法的,
|
||||
// 但 ESM 化(package.json 加 "type": "module" 或改 .mjs)后会立刻崩。
|
||||
// 改用 process.exit 更稳,跨模块系统都安全。
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
app.on('second-instance', () => {
|
||||
// 第二个实例启动了 → 把我们的悬浮窗唤出来,等价于用户点了托盘图标
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
showFloating();
|
||||
}
|
||||
});
|
||||
|
||||
// 必须发生在 app.whenReady 之前:把 Electron 的缓存目录显式指向用户数据里的子目录,
|
||||
// 避免每次启动时它去尝试「迁移默认缓存到 userData」——Windows 上迁移失败会刷一堆
|
||||
// cache_util_win.cc ERROR,虽然不影响功能但日志很难看。
|
||||
try {
|
||||
app.setPath('cache', path.join(app.getPath('appData'), 'quick-paste-cache'));
|
||||
} catch (e) {
|
||||
console.warn('set cache path failed:', e);
|
||||
}
|
||||
|
||||
// 仅在 Windows 上引入 koffi 调用系统 DLL
|
||||
let koffi = null;
|
||||
let user32 = null;
|
||||
let kernel32 = null;
|
||||
let SetForegroundWindow = null;
|
||||
let GetForegroundWindow = null;
|
||||
let GetWindowThreadProcessId = null;
|
||||
let AttachThreadInput = null;
|
||||
let GetCurrentThreadId = null;
|
||||
let IsWindow = null;
|
||||
let keybd_event = null;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
koffi = require('koffi');
|
||||
user32 = koffi.load('user32.dll');
|
||||
kernel32 = koffi.load('kernel32.dll');
|
||||
|
||||
SetForegroundWindow = user32.func('SetForegroundWindow', 'bool', ['void*']);
|
||||
GetForegroundWindow = user32.func('GetForegroundWindow', 'void*', []);
|
||||
GetWindowThreadProcessId = user32.func('GetWindowThreadProcessId', 'uint32', ['void*', 'void*']);
|
||||
AttachThreadInput = user32.func('AttachThreadInput', 'bool', ['uint32', 'uint32', 'bool']);
|
||||
IsWindow = user32.func('IsWindow', 'bool', ['void*']);
|
||||
keybd_event = user32.func('keybd_event', 'void', ['uint8', 'uint8', 'uint32', 'intptr']);
|
||||
|
||||
// GetCurrentThreadId 在 kernel32.dll
|
||||
GetCurrentThreadId = kernel32.func('GetCurrentThreadId', 'uint32', []);
|
||||
} catch (e) {
|
||||
console.error('koffi load failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const VK_CONTROL = 0x11;
|
||||
const VK_V = 0x56;
|
||||
const KEYEVENTF_KEYUP = 0x02;
|
||||
|
||||
const isDev = !app.isPackaged;
|
||||
// 注意:userData 路径依赖 app 初始化状态,在 whenReady 里再解析更稳妥
|
||||
let userDataPath = null;
|
||||
|
||||
// 构建标记:用户跑应用时看到这个 banner 就知道是含本次搜索框修复的最新代码。
|
||||
// 调整搜索框「无法输入」相关问题时同步修改此字符串。
|
||||
const BUILD_TAG = 'always-on-top-toggle-2026-09-11';
|
||||
console.log(`[quick-paste] build: ${BUILD_TAG}`);
|
||||
|
||||
let mainWindow = null;
|
||||
let tray = null;
|
||||
let isQuitting = false;
|
||||
|
||||
// 始终置顶开关:默认开启(这是悬浮粘贴工具的核心体验)。
|
||||
// 主进程持有「真相源」:窗口本身可能已被用户从 Windows 任务栏右键菜单里改过,
|
||||
// 但渲染进程保存的偏好才是用户期望的,启动时用它覆盖回去。
|
||||
let alwaysOnTop = true;
|
||||
|
||||
// 上次前台窗口句柄(用于粘贴前主动切回)
|
||||
let lastFgHwnd = null;
|
||||
let ourThreadId = 0;
|
||||
const ourPid = process.pid;
|
||||
// 前台窗口轮询定时器
|
||||
let fgTrackTimer = null;
|
||||
|
||||
/* ==================== 数据持久化 ==================== */
|
||||
const DATA_SCHEMA_VERSION = 1;
|
||||
// 短语内容上限:100KB,挡住手抖粘贴整个文件 / 滥用脚本导致剪贴板异常
|
||||
const MAX_PHRASE_CONTENT_LENGTH = 100 * 1024;
|
||||
|
||||
// 只读窗口位置(不参与完整数据校验,启动早期就要拿到)
|
||||
function readSavedBounds() {
|
||||
try {
|
||||
if (!userDataPath || !fs.existsSync(userDataPath)) return null;
|
||||
const raw = fs.readFileSync(userDataPath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
const b = data && data.settings && data.settings.windowBounds;
|
||||
if (
|
||||
b && Number.isFinite(b.x) && Number.isFinite(b.y) &&
|
||||
Number.isFinite(b.width) && Number.isFinite(b.height)
|
||||
) {
|
||||
// 多显示器:找到窗口中心点最近的显示器,把窗口整体夹回它的工作区。
|
||||
// 副屏拔了、分辨率改了导致窗口跑到屏幕外的情况都不会丢。
|
||||
const displays = screen.getAllDisplays();
|
||||
const cx = b.x + b.width / 2;
|
||||
const cy = b.y + b.height / 2;
|
||||
const nearest = screen.getDisplayNearestPoint({ x: cx, y: cy }) || displays[0];
|
||||
const wa = nearest.workArea;
|
||||
const width = Math.max(280, Math.min(b.width, wa.width, 800));
|
||||
const height = Math.max(360, Math.min(b.height, wa.height, 900));
|
||||
// 关键:x/y 必须保证窗口完整落在 wa 内,否则用户找不到窗口
|
||||
const x = Math.max(wa.x, Math.min(b.x, wa.x + wa.width - width));
|
||||
const y = Math.max(wa.y, Math.min(b.y, wa.y + wa.height - height));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function uid(prefix = 'id') {
|
||||
// 8 字节随机熵 ≈ 1.8e19 组合,比原来 4 字符 (~65K) 几乎不可能碰撞
|
||||
return `${prefix}_${Date.now().toString(36)}_${crypto.randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
function getDefaultData() {
|
||||
return {
|
||||
version: DATA_SCHEMA_VERSION,
|
||||
groups: [
|
||||
{
|
||||
id: 'grp_default',
|
||||
name: '常用',
|
||||
phrases: [
|
||||
{ id: 'phr_1', title: '你好', content: '你好,很高兴认识你!', fav: false, useCount: 0 },
|
||||
{ id: 'phr_2', title: '收到', content: '收到,我会尽快处理。', fav: false, useCount: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'grp_work',
|
||||
name: '工作',
|
||||
phrases: [
|
||||
{ id: 'phr_3', title: '开会通知', content: '各位同事,今天下午 3 点在会议室 A 开会,请准时参加。', fav: false, useCount: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeGroupId: 'grp_default',
|
||||
recent: [],
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
try {
|
||||
if (fs.existsSync(userDataPath)) {
|
||||
const raw = fs.readFileSync(userDataPath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
if (data && Array.isArray(data.groups)) {
|
||||
// 容错:每条 group 必须有 id/name/phrases 数组,过滤掉畸形项
|
||||
const cleaned = data.groups
|
||||
.filter((g) => g && typeof g === 'object')
|
||||
.map((g) => ({
|
||||
id: typeof g.id === 'string' ? g.id : uid('grp'),
|
||||
name: typeof g.name === 'string' ? g.name : '未命名分组',
|
||||
phrases: Array.isArray(g.phrases)
|
||||
? g.phrases
|
||||
.filter((p) => p && typeof p === 'object' && typeof p.content === 'string')
|
||||
.map((p) => ({
|
||||
id: typeof p.id === 'string' ? p.id : uid('phr'),
|
||||
title: typeof p.title === 'string' ? p.title : '',
|
||||
// 截断异常大的内容(防手抖 / 防被外部脚本注入巨型数据)
|
||||
content: p.content.slice(0, MAX_PHRASE_CONTENT_LENGTH),
|
||||
// 收藏标志。旧数据只有 pinned,这里一次性迁移成 fav。
|
||||
// 迁移后必须丢弃 pinned:否则 fav 已经被用户关掉、pinned 还留着 true 时,
|
||||
// 下次启动这个 `p.fav || p.pinned` 会把收藏又打开 —— 取消收藏永远存不住。
|
||||
fav: typeof p.fav === 'boolean' ? p.fav : !!p.pinned,
|
||||
// 使用次数(被粘贴/复制过的次数,统计用)
|
||||
useCount: typeof p.useCount === 'number' && p.useCount >= 0 ? p.useCount : 0,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
// 空分组必须保留!之前这里会把 phrases 为空的分组过滤掉,
|
||||
// 于是「新建分组」「清空短语」之后一重启,那个分组就凭空消失了。
|
||||
// 只有整份数据连一个分组都没有时才回落到默认数据。
|
||||
const groups = cleaned.length > 0 ? cleaned : getDefaultData().groups;
|
||||
// 最近使用列表:渲染进程维护的 [{id, ts}]
|
||||
const recent = Array.isArray(data.recent)
|
||||
? data.recent
|
||||
.filter((r) => r && typeof r === 'object' && typeof r.id === 'string')
|
||||
.slice(0, 50)
|
||||
: [];
|
||||
return {
|
||||
version: DATA_SCHEMA_VERSION,
|
||||
groups,
|
||||
activeGroupId:
|
||||
typeof data.activeGroupId === 'string' &&
|
||||
groups.some((g) => g.id === data.activeGroupId)
|
||||
? data.activeGroupId
|
||||
: groups[0].id,
|
||||
recent,
|
||||
// 透传 settings(如 windowBounds),让用户在主进程直接读写它
|
||||
settings: data.settings && typeof data.settings === 'object' ? data.settings : {},
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('load data error:', e);
|
||||
}
|
||||
return getDefaultData();
|
||||
}
|
||||
|
||||
// 原子化写盘:先写临时文件再 rename,避免崩溃时留下损坏的 data.json
|
||||
// 返回 { ok, error }:渲染端能区分成功 / 失败,把失败 toast 出来
|
||||
function saveData(data) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(userDataPath), { recursive: true });
|
||||
// 注入版本号;后续做迁移时按这个字段分支
|
||||
const payload = JSON.stringify({ ...data, version: DATA_SCHEMA_VERSION }, null, 2);
|
||||
const tmp = userDataPath + '.tmp';
|
||||
fs.writeFileSync(tmp, payload, 'utf-8');
|
||||
fs.renameSync(tmp, userDataPath);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error('save data error:', e);
|
||||
return { ok: false, error: e.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 窗口创建 ==================== */
|
||||
// 把 alwaysOnTop 的「真相源」应用到窗口上:不存在时直接 return。
|
||||
// 注意:必须保留旧的 setAlwaysOnTop(true, 'floating') 默认行为路径,
|
||||
// showFloating / pasteByHiding 仍然依赖它把窗口拉回顶层。
|
||||
function applyAlwaysOnTop() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
try {
|
||||
// 'floating' 是 Windows 上的「在最前面」层级,比 normal top 更高,
|
||||
// 大多数全屏应用都能压住它。
|
||||
mainWindow.setAlwaysOnTop(alwaysOnTop, 'floating');
|
||||
} catch (e) {
|
||||
console.error('applyAlwaysOnTop error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口位置记忆:用户拖到哪里下次就在哪里启动
|
||||
// 写盘是 debounce 的(300ms)避免拖动过程刷文件
|
||||
//
|
||||
// Bug 修复:原实现 `loadData() → 修改 → saveData()` 会从盘上读全量数据再合并窗口位置。
|
||||
// 如果渲染层在这 300ms 窗口内也调了 data:save 并完成落盘,主进程后续写盘会把磁盘
|
||||
// 上那份更新过的数据(用户最新的短语改动)覆盖掉,造成数据丢失。
|
||||
// 改为:缓存最近一次由 data:save 写入的完整数据快照,更新窗口位置时直接复用这份
|
||||
// 快照,绝不再从盘读。
|
||||
let cachedDataSnapshot = null;
|
||||
let saveBoundsTimer = null;
|
||||
function scheduleSaveBounds() {
|
||||
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
|
||||
saveBoundsTimer = setTimeout(() => {
|
||||
saveBoundsTimer = null;
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
try {
|
||||
const b = mainWindow.getBounds();
|
||||
// 优先用最近一次 data:save 传入的快照;快照不存在(应用刚启动、还没
|
||||
// 任何渲染层写入)时再退回到读盘路径。
|
||||
if (cachedDataSnapshot) {
|
||||
cachedDataSnapshot.settings = {
|
||||
...(cachedDataSnapshot.settings || {}),
|
||||
windowBounds: b,
|
||||
};
|
||||
saveData(cachedDataSnapshot);
|
||||
} else {
|
||||
const data = loadData();
|
||||
data.settings = { ...(data.settings || {}), windowBounds: b };
|
||||
saveData(data);
|
||||
cachedDataSnapshot = data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('save bounds error:', e);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
const { width: sw, height: sh } = display.workAreaSize;
|
||||
// 优先用上次保存的位置/大小;首启动或位置跑到屏幕外时回落默认
|
||||
const saved = readSavedBounds();
|
||||
const bounds = saved || {
|
||||
width: 380,
|
||||
height: 560,
|
||||
x: Math.max(0, sw - 400),
|
||||
y: 80,
|
||||
};
|
||||
|
||||
// 启动时把磁盘上的 alwaysOnTop 偏好读进来;
|
||||
// 旧数据没这个字段时 loadData 已经容错处理,settings 至少是 {},所以取默认值 true 即可。
|
||||
try {
|
||||
const persisted = loadData();
|
||||
const savedAlways = persisted && persisted.settings && persisted.settings.alwaysOnTop;
|
||||
alwaysOnTop = savedAlways === undefined ? true : !!savedAlways;
|
||||
} catch {
|
||||
alwaysOnTop = true;
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
...bounds,
|
||||
minWidth: 280,
|
||||
minHeight: 360,
|
||||
// 上限:避免被拖成全屏把内容扯稀烂;长内容让用户自己加大字号或滚动
|
||||
maxWidth: 800,
|
||||
maxHeight: 900,
|
||||
// 任务栏 / Alt-Tab 缩略图显示的图标。ico 内置 16/32/48 等多尺寸,
|
||||
// Windows 会按场景自动挑合适的;Linux/macOS 下 Electron 也会自动转换。
|
||||
icon: path.join(__dirname, 'icon.ico'),
|
||||
frame: false,
|
||||
// 初始值用 alwaysOnTop 偏好的临时快照(之前的临时快照就是模块级变量本身,
|
||||
// 这里直接读取即可);setAlwaysOnTop 由 applyAlwaysOnTop() 统一收口。
|
||||
alwaysOnTop,
|
||||
resizable: true,
|
||||
maximizable: false,
|
||||
minimizable: true,
|
||||
skipTaskbar: false,
|
||||
backgroundColor: '#08090A',
|
||||
title: '快速粘贴',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
applyAlwaysOnTop();
|
||||
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
||||
|
||||
// 窗口保持可聚焦(这样搜索框能直接接收键盘事件),但显示用 showInactive 不抢外部焦点。
|
||||
// 旧的 setNoActivate(true) 会让整窗口 setFocusable(false),搜索框也跟着收不到按键,
|
||||
// 那是无法接受的硬 bug。点短语走 hide → paste → show 路径,不依赖 noActivate 模式。
|
||||
mainWindow.setFocusable(true);
|
||||
|
||||
// 拖动 / 拉伸后记住位置
|
||||
mainWindow.on('move', scheduleSaveBounds);
|
||||
mainWindow.on('resize', scheduleSaveBounds);
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
showFloating();
|
||||
});
|
||||
|
||||
// 关闭按钮 → 最小化到托盘,而不是退出
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!isQuitting) {
|
||||
e.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 显示悬浮窗:用 showInactive 避免把焦点从目标输入框抢走。
|
||||
// 窗口本身保持 setFocusable(true)(见 createWindow),让搜索框能直接接收键盘事件。
|
||||
// 弹窗(编辑短语/新建分组等)需要主动接管键盘,仍由 setTyping 调用 mainWindow.focus()。
|
||||
function showFloating() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.showInactive();
|
||||
// showInactive 之后层级有时会被 Windows 重置;重新应用一次置顶偏好
|
||||
// 否则用户关掉置顶 → 隐藏 → 再唤起时窗口可能"复活"为置顶态。
|
||||
applyAlwaysOnTop();
|
||||
}
|
||||
|
||||
/* ==================== 托盘 ==================== */
|
||||
// 托盘图标使用项目根目录的 icon.ico:它内置了 16/32 等多种尺寸,
|
||||
// Windows 任务栏托盘会自动挑选最合适的那一档显示。
|
||||
// 原 fallback 路径(assets/tray.png)不再维护 —— 始终从 icon.ico 加载。
|
||||
const TRAY_ICON_PATH = path.join(__dirname, 'icon.ico');
|
||||
|
||||
function createTray() {
|
||||
let trayIcon;
|
||||
if (fs.existsSync(TRAY_ICON_PATH)) {
|
||||
trayIcon = nativeImage.createFromPath(TRAY_ICON_PATH);
|
||||
} else {
|
||||
// 极端兜底:icon.ico 不在时给个透明占位,避免 Tray 构造抛错把托盘拉崩
|
||||
trayIcon = nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
tray = new Tray(trayIcon);
|
||||
tray.setToolTip('快速粘贴');
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ label: '显示 / 隐藏', click: () => toggleWindow() },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
tray.setContextMenu(menu);
|
||||
tray.on('click', () => toggleWindow());
|
||||
}
|
||||
|
||||
function toggleWindow() {
|
||||
// isVisible()/hide()/show() 都会抛 "Object has been destroyed"
|
||||
// 在 quit 路径下(isQuitting=true、will-quit 还没反注册全局快捷键之前)可能撞上,
|
||||
// 这里显式拦一下,避免未捕获异常把渲染 / 托盘拉崩。
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
showFloating();
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 窗口句柄工具 ==================== */
|
||||
// koffi 返回的 HWND 是 External 指针对象,Electron 的 getNativeWindowHandle() 返回 Buffer,
|
||||
// 两者直接用 !== 比较永远不相等 → 必须统一换算成数值地址再比。
|
||||
function hwndAddr(hwnd) {
|
||||
if (!hwnd || !koffi) return 0n;
|
||||
try {
|
||||
return BigInt(koffi.address(hwnd));
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
|
||||
// 本进程所有窗口的句柄地址(兜底用)
|
||||
// 注意:用 process.arch 判断位数比看 Buffer.length 更稳——
|
||||
// Electron 在不同版本上 getNativeWindowHandle 返回 Buffer 的长度有时不完全等于本机指针宽度
|
||||
const IS_64BIT = process.arch === 'x64' || process.arch === 'arm64';
|
||||
function ownWindowAddrs() {
|
||||
const set = new Set();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed()) continue;
|
||||
try {
|
||||
const buf = win.getNativeWindowHandle();
|
||||
set.add(IS_64BIT ? buf.readBigUInt64LE(0) : BigInt(buf.readUInt32LE(0)));
|
||||
} catch {}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// 这个窗口是不是我们自己的(悬浮窗、弹窗等)
|
||||
// 直接走 ownWindowAddrs —— BrowserWindow.getNativeWindowHandle() 是 Electron 自己的稳定 API,
|
||||
// 比用 koffi `out()` 拿 PID 简单(koffi 在不同版本下 out 参数的回写行为不一致)。
|
||||
// 120ms 一次的轮询 + 遍历 1~3 个 BrowserWindow 完全可以忽略不计。
|
||||
function isOwnWindow(hwnd) {
|
||||
return ownWindowAddrs().has(hwndAddr(hwnd));
|
||||
}
|
||||
|
||||
/* ==================== 前台窗口跟踪 ==================== */
|
||||
function startForegroundTracking() {
|
||||
if (process.platform !== 'win32' || !GetForegroundWindow) return;
|
||||
ourThreadId = GetCurrentThreadId();
|
||||
|
||||
// 每 120ms 记录一次前台窗口,只记「不属于本应用」的窗口。
|
||||
// 用户点悬浮窗时保持上一次的目标不变,这样连点多条短语都会粘到同一个输入框。
|
||||
fgTrackTimer = setInterval(() => {
|
||||
try {
|
||||
const hwnd = GetForegroundWindow();
|
||||
if (!hwnd || hwndAddr(hwnd) === 0n) return;
|
||||
if (isOwnWindow(hwnd)) return;
|
||||
lastFgHwnd = hwnd;
|
||||
} catch {}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// 取当前可用的粘贴目标(窗口还在、且不是我们自己)
|
||||
function getPasteTarget() {
|
||||
if (!lastFgHwnd || !IsWindow) return null;
|
||||
try {
|
||||
if (!IsWindow(lastFgHwnd)) return null;
|
||||
if (isOwnWindow(lastFgHwnd)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return lastFgHwnd;
|
||||
}
|
||||
|
||||
// 把目标窗口拉回前台,返回是否成功
|
||||
function focusTarget(hwnd) {
|
||||
try {
|
||||
// 已经在前台(悬浮窗不抢焦点时的常态)→ 什么都不用做
|
||||
if (hwndAddr(GetForegroundWindow()) === hwndAddr(hwnd)) return true;
|
||||
|
||||
const targetTid = GetWindowThreadProcessId(hwnd, null);
|
||||
if (!targetTid) return false;
|
||||
if (targetTid === ourThreadId) return true;
|
||||
|
||||
// 临时附加输入线程,绕过前台窗口锁定
|
||||
AttachThreadInput(ourThreadId, targetTid, true);
|
||||
try {
|
||||
SetForegroundWindow(hwnd);
|
||||
} finally {
|
||||
AttachThreadInput(ourThreadId, targetTid, false);
|
||||
}
|
||||
return hwndAddr(GetForegroundWindow()) === hwndAddr(hwnd);
|
||||
} catch (e) {
|
||||
console.error('focusTarget error:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 立即触发 Ctrl+V ==================== */
|
||||
function sendCtrlV() {
|
||||
if (process.platform === 'win32' && keybd_event) {
|
||||
try {
|
||||
keybd_event(VK_CONTROL, 0, 0, 0);
|
||||
keybd_event(VK_V, 0, 0, 0);
|
||||
keybd_event(VK_V, 0, KEYEVENTF_KEYUP, 0);
|
||||
keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, 0);
|
||||
} catch (e) {
|
||||
console.error('sendCtrlV error:', e);
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
spawn('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);
|
||||
} else {
|
||||
spawn('xdotool', ['key', 'ctrl+v']);
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底方案:不知道该粘到哪 → 隐藏自己把焦点让给下面的窗口,粘完再无焦点显示回来
|
||||
// 拆成两个有明确语义的常量:
|
||||
// PASTE_HIDE_DELAY_MS:hide → keybd_event 之间的离场时间,避免 Ctrl+V 误发给「正在
|
||||
// 隐藏中」的本窗口(hide 是异步的,立即 sendCtrlV 可能撞上)
|
||||
// PASTE_RESTORE_DELAY_MS:sendCtrlV → showInactive 之间让目标应用接收并真正把
|
||||
// 剪贴板内容落到输入框。覆盖多数应用从切前台 → 输入框就绪的时间
|
||||
const PASTE_HIDE_DELAY_MS = 50;
|
||||
const PASTE_RESTORE_DELAY_MS = 200;
|
||||
// 用单 timer 取代「每次都 setTimeout」:连点多条短语时多个回显计时器会互相抢着触发,
|
||||
// 最后一个回显完成之前其它都会执行 → 窗口一闪一闪。把后续请求合并到同一个 timer,
|
||||
// 只回显一次即可。
|
||||
let pasteHideTimer = null;
|
||||
let pasteRestoreTimer = null;
|
||||
function pasteByHiding() {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
// 清掉前一轮还没触发的回显任务(窗口已经隐藏,再回调一次也是同一个状态)
|
||||
if (pasteRestoreTimer) { clearTimeout(pasteRestoreTimer); pasteRestoreTimer = null; }
|
||||
if (pasteHideTimer) clearTimeout(pasteHideTimer);
|
||||
pasteHideTimer = setTimeout(() => {
|
||||
pasteHideTimer = null;
|
||||
sendCtrlV();
|
||||
pasteRestoreTimer = setTimeout(() => {
|
||||
pasteRestoreTimer = null;
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
|
||||
mainWindow.showInactive();
|
||||
// 同 showFloating:showInactive 会重置层级,必须按当前偏好重新应用
|
||||
applyAlwaysOnTop();
|
||||
}
|
||||
}, PASTE_RESTORE_DELAY_MS - PASTE_HIDE_DELAY_MS);
|
||||
}, PASTE_HIDE_DELAY_MS);
|
||||
}
|
||||
|
||||
/* ==================== 占位符展开 ==================== */
|
||||
// 在粘贴/复制前把 {date} / {time} / {datetime} / {weekday} / {clipboard} 替换成实际值。
|
||||
// 在主进程做是为了 `{clipboard}` 能可靠地读到系统剪贴板(避免渲染层权限差异)。
|
||||
// 调用方需自己先快照原剪贴板内容(防止被自身的 writeText 覆盖)。
|
||||
const WEEKDAY_CN = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
function expandPlaceholders(text, clipboardSnapshot = '') {
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const replacements = {
|
||||
date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
|
||||
time: `${pad(now.getHours())}:${pad(now.getMinutes())}`,
|
||||
weekday: WEEKDAY_CN[now.getDay()],
|
||||
clipboard: clipboardSnapshot,
|
||||
};
|
||||
replacements.datetime = `${replacements.date} ${replacements.time}`;
|
||||
// 单次正则遍历替代 5 次 replace。支持 \{xxx\} 反斜杠转义——
|
||||
// 想在短语里写「{date}」字面量教别人用占位符时,加一个反斜杠就行。
|
||||
return text.replace(
|
||||
/\\?\{(date|time|datetime|weekday|clipboard)\}/g,
|
||||
(m, key) => m.startsWith('\\') ? `{${key}}` : replacements[key]
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== IPC ==================== */
|
||||
// windowBounds 只由主进程维护(move/resize 事件)。渲染进程保存的是它启动时读到的
|
||||
// 那份 settings 快照,里面的 windowBounds 已经过期——直接落盘会把用户刚拖好的窗口
|
||||
// 位置回滚。所以每次写盘前都用「当前真实窗口位置」覆盖这个字段。
|
||||
function withOwnedSettings(incoming) {
|
||||
const out = { ...(incoming || {}) };
|
||||
const settings = { ...(out.settings || {}) };
|
||||
let bounds = null;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
try {
|
||||
bounds = mainWindow.getBounds();
|
||||
} catch {}
|
||||
}
|
||||
// 窗口不可用(退出过程中)时回落到磁盘上已有的值,避免把字段清掉
|
||||
if (!bounds) {
|
||||
try {
|
||||
const disk = loadData();
|
||||
bounds = disk && disk.settings ? disk.settings.windowBounds : null;
|
||||
} catch {}
|
||||
}
|
||||
if (bounds) settings.windowBounds = bounds;
|
||||
out.settings = settings;
|
||||
return out;
|
||||
}
|
||||
|
||||
ipcMain.handle('data:load', () => loadData());
|
||||
ipcMain.handle('data:save', (_, data) => {
|
||||
const enriched = withOwnedSettings(data);
|
||||
const res = saveData(enriched);
|
||||
// 关键:先 saveData 再决定是否更新快照。saveData 失败时保持上一次成功状态,
|
||||
// 否则 scheduleSaveBounds 会把磁盘上本不该存在的那份失败数据当成「真实状态」写盘。
|
||||
if (res && res.ok) {
|
||||
// 维护一份最近一次写入的全量数据快照。scheduleSaveBounds 用它来同步窗口位置,
|
||||
// 不再从盘读 —— 避免和渲染层的并发保存产生覆盖。
|
||||
// 浅拷贝即可:data 来自渲染进程 IPC structured clone、withOwnedSettings 也已经
|
||||
// 浅拷贝过 settings,本进程内部无须再 JSON.parse(JSON.stringify(...)) 深拷贝一次。
|
||||
// 后续 scheduleSaveBounds 会改写 settings.windowBounds,那块新对象也独立。
|
||||
cachedDataSnapshot = { ...enriched, settings: { ...enriched.settings } };
|
||||
// data:save 刚刚成功落盘,待处理的窗口位置合并任务无意义了 —— 下一次
|
||||
// move/resize 会重新 schedule。提前清掉避免它再用一份过期快照覆盖磁盘。
|
||||
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
||||
}
|
||||
return res;
|
||||
});
|
||||
// 同步版本:用于 beforeunload 场景,sendSync 会阻塞渲染进程直到写盘完成
|
||||
ipcMain.on('data:saveSync', (e, data) => {
|
||||
const enriched = withOwnedSettings(data);
|
||||
const res = saveData(enriched);
|
||||
if (res && res.ok) {
|
||||
cachedDataSnapshot = { ...enriched, settings: { ...enriched.settings } };
|
||||
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
||||
}
|
||||
e.returnValue = res;
|
||||
});
|
||||
|
||||
// 仅复制到剪贴板,不触发粘贴;和 `paste` 共用同一套占位符展开,保持一致性
|
||||
ipcMain.handle('clipboard:copy', (_, text) => {
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return { ok: false, error: '内容为空' };
|
||||
}
|
||||
// 同样的 100KB 上限挡住滥用:和 paste 对齐
|
||||
if (text.length > MAX_PHRASE_CONTENT_LENGTH) {
|
||||
return { ok: false, error: `内容过长(${text.length} > ${MAX_PHRASE_CONTENT_LENGTH})` };
|
||||
}
|
||||
try {
|
||||
// 含 {clipboard} 时,先快照原剪贴板再覆盖,避免读到自身写出去的内容
|
||||
const snapshot = text.includes('{clipboard}') ? clipboard.readText() : '';
|
||||
const expanded = expandPlaceholders(text, snapshot);
|
||||
clipboard.writeText(expanded);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error('clipboard:copy error:', e);
|
||||
return { ok: false, error: e.message || String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('paste', (_, text, opts) => {
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return { ok: false, error: '内容为空' };
|
||||
}
|
||||
// 剪贴板巨型写入会卡顿甚至触发系统限制:超过 100KB 就拒绝
|
||||
if (text.length > MAX_PHRASE_CONTENT_LENGTH) {
|
||||
return { ok: false, error: `内容过长(${text.length} > ${MAX_PHRASE_CONTENT_LENGTH})` };
|
||||
}
|
||||
|
||||
// 1. 含 {clipboard} 时必须先快照原剪贴板,否则下一步 writeText 会覆盖掉要读的内容
|
||||
const clipboardSnapshot = text.includes('{clipboard}') ? clipboard.readText() : '';
|
||||
const expanded = expandPlaceholders(text, clipboardSnapshot);
|
||||
|
||||
// 2. 写入剪贴板
|
||||
try {
|
||||
clipboard.writeText(expanded);
|
||||
} catch (e) {
|
||||
console.error('clipboard.writeText error:', e);
|
||||
return { ok: false, error: '剪贴板写入失败' };
|
||||
}
|
||||
|
||||
// 2.5 用户在设置里开了「粘贴时隐藏悬浮窗」→ 必须走 pasteByHiding,
|
||||
// 因为只有它会在 PASTE_RESTORE_DELAY_MS 后把窗口重新显示出来。
|
||||
// (之前由渲染进程先调 window:hide 再走下面的「有目标」分支,
|
||||
// 那条分支不负责重显,窗口就一直留在隐藏状态回不来了。)
|
||||
if (opts && opts.hideFirst) {
|
||||
pasteByHiding();
|
||||
return { ok: true, hasTarget: !!getPasteTarget() };
|
||||
}
|
||||
|
||||
// 3. 取目标窗口(非 Windows 平台没有句柄跟踪,一律走兜底)
|
||||
const target = process.platform === 'win32' && keybd_event ? getPasteTarget() : null;
|
||||
|
||||
// 4. 没有已知目标 → 兜底:隐藏自己让焦点回落,粘完再无焦点显示回来
|
||||
if (!target) {
|
||||
pasteByHiding();
|
||||
// 告诉渲染端:剪贴板已写入,但目标窗口未知,需要用户手动点一下输入框
|
||||
return { ok: true, hasTarget: false };
|
||||
}
|
||||
|
||||
// 5. 有明确目标:拉回前台后直接注入 Ctrl+V。
|
||||
// 这里刻意不隐藏 / 重显悬浮窗 —— 连点多条短语时窗口一直在原地不闪,
|
||||
// 目标输入框也始终是同一个,不用再回去点一次输入框。
|
||||
// 如果拉前台失败(被系统拒绝、目标在切换中等),退回兜底走隐藏-粘贴-回显流程,
|
||||
// 避免把 Ctrl+V 误发到自己窗口里。
|
||||
if (!focusTarget(target)) {
|
||||
pasteByHiding();
|
||||
return { ok: true, hasTarget: false };
|
||||
}
|
||||
sendCtrlV();
|
||||
|
||||
return { ok: true, hasTarget: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('window:hide', () => { if (mainWindow) mainWindow.hide(); });
|
||||
// 切换「始终置顶」。渲染进程负责把偏好写进 data.settings(持久化),
|
||||
// 主进程只负责:1) 立即应用;2) 后续 showFloating / pasteByHiding 路径拿这个状态。
|
||||
ipcMain.handle('window:setAlwaysOnTop', (_, on) => {
|
||||
alwaysOnTop = !!on;
|
||||
applyAlwaysOnTop();
|
||||
return alwaysOnTop;
|
||||
});
|
||||
// 调整窗口透明度(用户设置里 60~100)
|
||||
ipcMain.handle('window:setOpacity', (_, value) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return false;
|
||||
// 注意:不能用 `Number(value) || 1`——0 || 1 === 1 把 0 当成 falsy 落到默认值了。
|
||||
// 用 Number.isFinite 显式判 NaN/Infinity/非数字字符串。
|
||||
const v = Math.max(0.6, Math.min(1, Number(value)));
|
||||
if (!Number.isFinite(v)) return false;
|
||||
try {
|
||||
mainWindow.setOpacity(v);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('setOpacity error:', e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
ipcMain.handle('app:quit', () => { isQuitting = true; app.quit(); });
|
||||
|
||||
// 渲染进程要打字了(打开编辑弹窗)→ 临时让窗口获得焦点;
|
||||
// 关掉弹窗后只把焦点还给原来的目标窗口,窗口本身仍保持可聚焦
|
||||
// (搜索框需要持续可用;如果这里 setNoActivate(true) 会让搜索框跟着失能)。
|
||||
ipcMain.handle('window:setTyping', (_, typing) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return false;
|
||||
if (process.platform !== 'win32') return true;
|
||||
|
||||
try {
|
||||
if (typing) {
|
||||
mainWindow.focus();
|
||||
} else {
|
||||
const target = getPasteTarget();
|
||||
// 没有目标(比如刚启动、还没有任何前台窗口轮询过)→ 啥也不做,让窗口自然失焦
|
||||
if (target) focusTarget(target);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('setTyping error:', e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 返回数据文件路径(绝对路径)
|
||||
ipcMain.handle('app:getDataPath', () => userDataPath);
|
||||
|
||||
// 在文件管理器中定位数据文件
|
||||
ipcMain.handle('app:revealDataFile', () => {
|
||||
try {
|
||||
if (fs.existsSync(userDataPath)) {
|
||||
shell.showItemInFolder(userDataPath);
|
||||
} else {
|
||||
// 文件还不存在(首次启动未保存)→ 打开所在目录
|
||||
shell.openPath(path.dirname(userDataPath));
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('reveal data file error:', e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 在系统默认浏览器中打开外部链接
|
||||
// 渲染进程走的是 file:// + contextIsolation,直接 <a target="_blank"> 在 Electron 里
|
||||
// 不会自动跳出当前窗口。让渲染进程通过 IPC 把 URL 交给主进程,由 shell.openExternal
|
||||
// 调用系统浏览器打开 —— 这样也顺便强制校验协议头,避免被劫持去打开 file:// 之类。
|
||||
ipcMain.handle('app:openExternal', (_evt, url) => {
|
||||
try {
|
||||
const u = String(url || '').trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
console.warn('openExternal rejected non-http(s) url:', u);
|
||||
return false;
|
||||
}
|
||||
shell.openExternal(u);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('openExternal error:', e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 导出数据:弹保存对话框,把当前数据写到用户选的 .json 文件
|
||||
ipcMain.handle('data:export', async () => {
|
||||
try {
|
||||
const current = loadData();
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const defaultName = `quick-paste-${stamp}.json`;
|
||||
const res = await dialog.showSaveDialog(mainWindow || undefined, {
|
||||
title: '导出短语',
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
});
|
||||
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
|
||||
fs.writeFileSync(res.filePath, JSON.stringify(current, null, 2), 'utf-8');
|
||||
return { ok: true, filePath: res.filePath };
|
||||
} catch (e) {
|
||||
console.error('export error:', e);
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== 生命周期 ==================== */
|
||||
// 全局快捷键:Ctrl+Shift+V 在系统任何地方都能唤出 / 收起悬浮窗
|
||||
// README / 帮助面板里都列着这条快捷键,缺了等于核心交互路径断了
|
||||
const GLOBAL_TOGGLE_SHORTCUT = 'CommandOrControl+Shift+V';
|
||||
|
||||
app.whenReady().then(() => {
|
||||
userDataPath = path.join(app.getPath('userData'), 'data.json');
|
||||
createWindow();
|
||||
createTray();
|
||||
|
||||
// 启动前台窗口跟踪
|
||||
startForegroundTracking();
|
||||
|
||||
// 注册全局唤出快捷键。register 失败通常是别的程序占了这个组合键
|
||||
// (比如某些截图工具也用 Ctrl+Shift+V)——失败就静默退化到只通过托盘点击唤出,
|
||||
// 不阻塞应用启动。
|
||||
try {
|
||||
const ok = globalShortcut.register(GLOBAL_TOGGLE_SHORTCUT, () => toggleWindow());
|
||||
if (!ok) console.warn('全局快捷键注册失败,可能被其它应用占用:', GLOBAL_TOGGLE_SHORTCUT);
|
||||
} catch (e) {
|
||||
console.warn('globalShortcut.register error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
// 退出时反注册所有全局快捷键,避免下次的注册失败
|
||||
try { globalShortcut.unregisterAll(); } catch {}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
// 清理窗口位置合并任务:避免退出后定时器回调访问已销毁的 mainWindow
|
||||
// (回调里有 isDestroyed 检查不会崩,但清理掉更对称、也省一次空写盘)
|
||||
if (saveBoundsTimer) {
|
||||
clearTimeout(saveBoundsTimer);
|
||||
saveBoundsTimer = null;
|
||||
}
|
||||
// 清理前台窗口轮询定时器,避免退出后仍在访问主窗口
|
||||
if (fgTrackTimer) {
|
||||
clearInterval(fgTrackTimer);
|
||||
fgTrackTimer = null;
|
||||
}
|
||||
// 清理回显 timer,避免退出后还会触发 mainWindow.showInactive 撞到销毁态
|
||||
if (pasteRestoreTimer) {
|
||||
clearTimeout(pasteRestoreTimer);
|
||||
pasteRestoreTimer = null;
|
||||
}
|
||||
// 清理 hide→paste 延迟 timer,同上理由(回调里 sendCtrlV 会在已销毁进程上下文里跑)
|
||||
if (pasteHideTimer) {
|
||||
clearTimeout(pasteHideTimer);
|
||||
pasteHideTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
else if (mainWindow) showFloating();
|
||||
});
|
||||
4126
package-lock.json
generated
Normal file
4126
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "quick-paste",
|
||||
"version": "1.0.0",
|
||||
"description": "悬浮快速粘贴工具 — 分组管理常用句子,点击即粘贴到当前焦点窗口",
|
||||
"main": "main.js",
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "node ./scripts/start.js",
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "electron-builder"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^31.7.7",
|
||||
"electron-builder": "^24.13.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^2.16.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.example.quickpaste",
|
||||
"productName": "快速粘贴",
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"renderer/**/*",
|
||||
"icon.ico",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "快速粘贴"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
preload.js
Normal file
41
preload.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 预加载脚本:通过 contextBridge 把主进程能力安全地暴露给渲染进程
|
||||
*/
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// 数据
|
||||
loadData: () => ipcRenderer.invoke('data:load'),
|
||||
saveData: (data) => ipcRenderer.invoke('data:save', data),
|
||||
// 同步写盘:仅用于 beforeunload 等不能 await 的场景,调用会阻塞渲染进程直到主进程写完
|
||||
saveDataSync: (data) => ipcRenderer.sendSync('data:saveSync', data),
|
||||
|
||||
// 核心:把文本粘贴到当前焦点窗口
|
||||
// opts.hideFirst = true 时主进程走「隐藏 → Ctrl+V → 重新显示」流程
|
||||
paste: (text, opts) => ipcRenderer.invoke('paste', text, opts || {}),
|
||||
|
||||
// 仅复制到剪贴板,不触发粘贴(用于「复制」按钮)
|
||||
copy: (text) => ipcRenderer.invoke('clipboard:copy', text),
|
||||
|
||||
// 窗口控制
|
||||
hideWindow: () => ipcRenderer.invoke('window:hide'),
|
||||
// 切换「始终置顶」,返回应用后的实际状态
|
||||
setAlwaysOnTop: (on) => ipcRenderer.invoke('window:setAlwaysOnTop', !!on),
|
||||
quitApp: () => ipcRenderer.invoke('app:quit'),
|
||||
|
||||
// 要打字了(打开/关闭编辑弹窗)→ 临时允许窗口获得键盘焦点
|
||||
setTyping: (typing) => ipcRenderer.invoke('window:setTyping', typing),
|
||||
|
||||
// 调整窗口透明度(设置面板里的滑块)
|
||||
setOpacity: (value) => ipcRenderer.invoke('window:setOpacity', value),
|
||||
|
||||
// 数据文件路径
|
||||
getDataPath: () => ipcRenderer.invoke('app:getDataPath'),
|
||||
revealDataFile: () => ipcRenderer.invoke('app:revealDataFile'),
|
||||
|
||||
// 在系统默认浏览器中打开外部链接(设置面板「关于」区使用)
|
||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||
|
||||
// 导出(保存对话框需要主进程;导入走隐藏 file input,无需 IPC)
|
||||
exportData: () => ipcRenderer.invoke('data:export'),
|
||||
});
|
||||
481
renderer/index.html
Normal file
481
renderer/index.html
Normal file
@@ -0,0 +1,481 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' file:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: file:;" />
|
||||
<title>快速粘贴</title>
|
||||
<!-- 浏览器标签页 / 收藏夹 / 桌面快捷方式都会用到的 favicon:
|
||||
用项目根的 icon.ico,Chromium 内核会自动挑合适尺寸。 -->
|
||||
<link rel="icon" type="image/x-icon" href="../icon.ico" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- ====== SVG 图标精灵(统一引用) ====== -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="i-pin" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 4l5 5-2 2-1 4-4 1-1 4-3-3-5 5 5-5-3-3 4-1 1-4 4-1 2-2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-tray" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4 13v5a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-5M4 13h4l1 2h6l1-2h4M4 13l1-6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1l1 6" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24" fill="none">
|
||||
<path d="m4 20 4-1 10-10-3-3L5 16l-1 4z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="m14 6 3 3" stroke="currentColor" stroke-width="1.6"/>
|
||||
</symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 7h14M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M7 7l1 12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1l1-12" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M16 8V6a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2" stroke="currentColor" stroke-width="1.6"/>
|
||||
</symbol>
|
||||
<symbol id="i-arrow-up" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 19V5M6 11l6-6 6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-arrow-down" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 5v14M6 13l6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-recent" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M12 7v5l3 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-star" viewBox="0 0 24 24" fill="none">
|
||||
<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.2L12 17.5l-5.5 2.7 1.1-6.2L3 9.6l6.3-.9L12 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-star-fill" viewBox="0 0 24 24">
|
||||
<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.2L12 17.5l-5.5 2.7 1.1-6.2L3 9.6l6.3-.9L12 3z" fill="currentColor"/>
|
||||
</symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none">
|
||||
<path d="m5 12 5 5 9-11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 3 2 21h20L12 3z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<path d="M12 10v5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="18" r="0.9" fill="currentColor"/>
|
||||
</symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M12 11v6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="7.5" r="0.9" fill="currentColor"/>
|
||||
</symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-moon" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 14A8 8 0 1 1 10 4a6 6 0 0 0 10 10z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-paste-icon" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="6" y="4" width="12" height="16" rx="2" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M9 4h6v3H9z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<path d="M9 12h6M9 16h4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<!-- 折叠:左侧栏往左收 -->
|
||||
<symbol id="i-sidebar-collapse" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="m13 10-3 2 3 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<!-- 展开:左侧栏从左侧展开 -->
|
||||
<symbol id="i-sidebar-expand" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="m11 10 3 2-3 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<!-- 置顶:标准图钉(圆角矩形头 + 针尖)。开关靠 .toggle-on 高亮态区分 -->
|
||||
<symbol id="i-pin-on" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 3a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v3l2 3H7l2-3V3z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<path d="M8 9l-2 3h12l-2-3" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12 12v9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<!-- 外部链接:方框 + 右上方箭头,经典「在新窗口打开」语义 -->
|
||||
<symbol id="i-external" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 4h6v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20 4 10 14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path d="M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- ====== 标题栏 ====== -->
|
||||
<header id="titlebar">
|
||||
<div class="title">
|
||||
<span class="logo">
|
||||
<!-- 任务栏/Alt-Tab 已经在用 icon.ico,标题栏左上角的徽标也用同一份图标保持一致。
|
||||
ICO 内置多尺寸,Chromium 会按容器尺寸自动挑最接近的那一档渲染。 -->
|
||||
<img class="logo-img" src="../icon.ico" alt="快速粘贴" />
|
||||
</span>
|
||||
<span class="title-name">快速粘贴</span>
|
||||
</div>
|
||||
<div class="title-actions">
|
||||
<button id="btn-pin-top" class="icon-btn toggle-on" title="始终置顶(点击切换)" aria-label="切换始终置顶" aria-pressed="true">
|
||||
<svg class="icon"><use href="#i-pin-on"/></svg>
|
||||
</button>
|
||||
<button id="btn-settings" class="icon-btn" title="设置">
|
||||
<svg class="icon"><use href="#i-settings"/></svg>
|
||||
</button>
|
||||
<button id="btn-hide" class="icon-btn" title="关闭" aria-label="关闭(最小化到托盘)">
|
||||
<svg class="icon"><use href="#i-x"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ====== 主体 ====== -->
|
||||
<main id="main">
|
||||
<!-- 左侧分组 -->
|
||||
<aside id="sidebar" aria-label="分组与快捷视图">
|
||||
<!-- 侧栏顶部:收藏 / 最近(跨分组的快捷入口,独立区域) -->
|
||||
<div class="sidebar-quick">
|
||||
<button class="quick-item" data-filter="fav" title="收藏(F)">
|
||||
<svg class="icon"><use href="#i-star"/></svg>
|
||||
<span class="name">收藏</span>
|
||||
<span class="count" id="quick-count-fav">0</span>
|
||||
</button>
|
||||
<button class="quick-item" data-filter="recent" title="最近使用(R)">
|
||||
<svg class="icon"><use href="#i-recent"/></svg>
|
||||
<span class="name">最近</span>
|
||||
<span class="count" id="quick-count-recent">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-header">
|
||||
<span>分组</span>
|
||||
<button id="btn-add-group" class="mini-btn" title="新建分组">
|
||||
<svg class="icon"><use href="#i-plus"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul id="group-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button id="btn-toggle-sidebar" class="icon-btn sidebar-toggle-btn" title="折叠分组栏" aria-label="折叠分组栏" aria-pressed="true">
|
||||
<svg class="icon"><use href="#i-sidebar-collapse"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<section id="content">
|
||||
<div class="content-header">
|
||||
<div class="content-header-left">
|
||||
<h2 id="current-group-name">常用</h2>
|
||||
<span id="phrase-count" class="phrase-count hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<svg class="icon search-icon"><use href="#i-search"/></svg>
|
||||
<input id="search-input" type="text" placeholder="搜索短语…" autocomplete="off" spellcheck="false" />
|
||||
<button id="search-clear" class="search-clear hidden" title="清空">
|
||||
<svg class="icon"><use href="#i-x"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 过滤器已移至左侧栏顶部(收藏 / 最近);这里不再保留 -->
|
||||
<ul id="phrase-list"></ul>
|
||||
|
||||
<div id="phrase-empty" class="empty-hint hidden">
|
||||
<div class="empty-illustration" id="empty-illustration">
|
||||
<svg viewBox="0 0 64 64" fill="none">
|
||||
<rect x="14" y="10" width="32" height="44" rx="4" stroke="currentColor" stroke-width="1.6" opacity="0.4"/>
|
||||
<path d="M22 22h16M22 28h16M22 34h10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" opacity="0.4"/>
|
||||
<circle cx="46" cy="46" r="10" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M46 41v10M41 46h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="empty-title">这个分组还是空的</div>
|
||||
<div class="empty-desc">点下方按钮添加你的第一条常用语</div>
|
||||
<button id="empty-add-btn" class="empty-action">
|
||||
<svg class="icon"><use href="#i-plus"/></svg>
|
||||
添加第一条短语
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="add-bar">
|
||||
<!-- 仅在侧栏折叠时显示:把按钮重新放回「+ 添加短语」左边,
|
||||
这样折叠态下用户不用回到标题栏 / 托盘去找展开按钮 -->
|
||||
<button id="btn-expand-sidebar" class="icon-btn sidebar-expand-btn" title="展开分组栏" aria-label="展开分组栏" aria-pressed="false">
|
||||
<svg class="icon"><use href="#i-sidebar-expand"/></svg>
|
||||
</button>
|
||||
<button id="btn-add-phrase" class="primary-btn">
|
||||
<svg class="icon"><use href="#i-plus"/></svg>
|
||||
<span>添加短语</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ====== 短语编辑弹窗 ====== -->
|
||||
<div id="modal" class="modal hidden">
|
||||
<div class="modal-card phrase-modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title-wrap">
|
||||
<svg class="icon modal-title-icon"><use href="#i-paste-icon"/></svg>
|
||||
<span id="modal-title">添加短语</span>
|
||||
</span>
|
||||
<button id="modal-close" class="icon-btn" title="关闭">
|
||||
<svg class="icon"><use href="#i-x"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="field-label">
|
||||
<span>标题</span>
|
||||
<span class="field-counter" id="title-counter">0 / 40</span>
|
||||
</label>
|
||||
<input id="phrase-title" type="text" placeholder="比如:客服开场白" maxlength="40" />
|
||||
|
||||
<label class="field-label">
|
||||
<span>内容</span>
|
||||
<span class="field-counter" id="content-counter">0 / 2000</span>
|
||||
</label>
|
||||
<textarea id="phrase-content" rows="6" maxlength="2000"
|
||||
placeholder="点击后会粘贴这段文字到当前焦点窗口"></textarea>
|
||||
|
||||
<!-- 占位符提示:插入变量后会在这里实时显示展开结果 -->
|
||||
<div id="placeholder-preview" class="placeholder-preview hidden">
|
||||
<div class="placeholder-preview-label">展开预览</div>
|
||||
<div id="placeholder-preview-text" class="placeholder-preview-text"></div>
|
||||
</div>
|
||||
|
||||
<details class="placeholder-hint">
|
||||
<summary>支持的变量</summary>
|
||||
<div class="placeholder-list">
|
||||
<span><code>{date}</code> 当前日期(2026-09-11)</span>
|
||||
<span><code>{time}</code> 当前时间(14:30)</span>
|
||||
<span><code>{datetime}</code> 日期 + 时间</span>
|
||||
<span><code>{weekday}</code> 周几(周一)</span>
|
||||
<span><code>{clipboard}</code> 当前剪贴板内容</span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label-inline">
|
||||
<input id="phrase-fav" type="checkbox" />
|
||||
<svg class="icon"><use href="#i-star"/></svg>
|
||||
<span>加入收藏</span>
|
||||
</label>
|
||||
<span class="field-tip">收藏后会显示在顶部分类「收藏」中</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button id="modal-cancel" class="text-btn">取消</button>
|
||||
<button id="modal-save" class="primary-btn" disabled>
|
||||
<svg class="icon"><use href="#i-check"/></svg>
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 通用提示/输入弹窗 ====== -->
|
||||
<div id="prompt-modal" class="modal hidden">
|
||||
<div class="modal-card prompt-card">
|
||||
<div class="modal-header">
|
||||
<span class="prompt-icon" id="prompt-icon">
|
||||
<svg class="icon"><use href="#i-info"/></svg>
|
||||
</span>
|
||||
<span id="prompt-title">提示</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="prompt-message" class="prompt-message"></p>
|
||||
<input id="prompt-input" type="text" class="prompt-input" maxlength="60" />
|
||||
<div class="modal-actions">
|
||||
<button id="prompt-cancel" class="text-btn">取消</button>
|
||||
<button id="prompt-ok" class="primary-btn">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 上下文菜单 ====== -->
|
||||
<div id="context-menu" class="context-menu hidden"></div>
|
||||
|
||||
<!-- ====== 设置面板 ====== -->
|
||||
<div id="settings-panel" class="panel hidden">
|
||||
<div class="panel-backdrop"></div>
|
||||
<div class="panel-card settings-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon"><use href="#i-settings"/></svg>
|
||||
设置
|
||||
</span>
|
||||
<button class="icon-btn" data-close>
|
||||
<svg class="icon"><use href="#i-x"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="setting-group">
|
||||
<div class="setting-group-title">外观</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">主题</div>
|
||||
<div class="setting-desc">切换明暗主题</div>
|
||||
</div>
|
||||
<div class="segmented" id="theme-segmented">
|
||||
<button data-theme="dark" class="seg-btn active">
|
||||
<svg class="icon"><use href="#i-moon"/></svg>
|
||||
暗
|
||||
</button>
|
||||
<button data-theme="light" class="seg-btn">
|
||||
<svg class="icon"><use href="#i-sun"/></svg>
|
||||
亮
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">窗口透明度</div>
|
||||
<div class="setting-desc"><span id="opacity-value">100</span>%</div>
|
||||
</div>
|
||||
<input type="range" id="opacity-slider" min="60" max="100" step="5" value="100" class="slider" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="setting-group-title">粘贴</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">点击短语时</div>
|
||||
<div class="setting-desc">单击短语的默认行为</div>
|
||||
</div>
|
||||
<div class="segmented" id="click-action-segmented">
|
||||
<button data-action="paste" class="seg-btn active">直接粘贴</button>
|
||||
<button data-action="copy" class="seg-btn">仅复制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">粘贴时隐藏悬浮窗</div>
|
||||
<div class="setting-desc">关闭后悬浮窗会立即重新显示</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="hide-on-paste-toggle" />
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="setting-group-title">最近使用</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">记录最近使用</div>
|
||||
<div class="setting-desc">在「最近」分类中显示粘贴历史</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="recent-toggle" checked />
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" id="clear-recent-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">清空最近记录</div>
|
||||
<div class="setting-desc" id="recent-count-desc">还没有记录</div>
|
||||
</div>
|
||||
<button class="text-btn danger" id="btn-clear-recent">
|
||||
<svg class="icon"><use href="#i-trash"/></svg>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" id="clear-usecount-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">清空使用统计</div>
|
||||
<div class="setting-desc" id="usecount-count-desc">还没有使用记录</div>
|
||||
</div>
|
||||
<button class="text-btn danger" id="btn-clear-usecount">
|
||||
<svg class="icon"><use href="#i-trash"/></svg>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="setting-group-title">数据</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">数据文件路径</div>
|
||||
<div class="setting-desc mono" id="data-path-value">—</div>
|
||||
</div>
|
||||
<button class="text-btn" id="btn-reveal-file-2">
|
||||
<svg class="icon"><use href="#i-folder"/></svg>
|
||||
打开
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">导入 / 导出</div>
|
||||
<div class="setting-desc">备份或恢复你的短语库</div>
|
||||
</div>
|
||||
<div class="setting-actions">
|
||||
<button class="text-btn" id="btn-export">
|
||||
<svg class="icon"><use href="#i-arrow-down"/></svg>
|
||||
导出
|
||||
</button>
|
||||
<button class="text-btn" id="btn-import">
|
||||
<svg class="icon"><use href="#i-arrow-up"/></svg>
|
||||
导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="setting-group-title">关于</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-text">
|
||||
<div class="setting-label">开发者</div>
|
||||
<div class="setting-desc">
|
||||
关济寰 ·
|
||||
<a href="https://www.guanjihuan.com/about" id="link-developer" class="setting-link">https://www.guanjihuan.com/about</a>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-btn" id="btn-open-developer" title="在浏览器中打开">
|
||||
<svg class="icon"><use href="#i-external"/></svg>
|
||||
打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 隐藏的文件选择器(用于导入) ====== -->
|
||||
<input type="file" id="import-file-input" accept="application/json" hidden />
|
||||
|
||||
<!-- ====== 提示 Toast 栈 ====== -->
|
||||
<div id="toast-stack" class="toast-stack"></div>
|
||||
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1947
renderer/renderer.js
Normal file
1947
renderer/renderer.js
Normal file
File diff suppressed because it is too large
Load Diff
1939
renderer/styles.css
Normal file
1939
renderer/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
48
scripts/start.js
Normal file
48
scripts/start.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 启动脚本:清除 ELECTRON_RUN_AS_NODE 等会干扰 Electron 的环境变量,
|
||||
* 然后直接 spawn electron.exe
|
||||
*
|
||||
* 解决 Git Bash / npm 环境里 ELECTRON_RUN_AS_NODE=1 导致 Electron 退化为 Node.js 的问题
|
||||
*/
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// 关键:清除会让 Electron 退化为普通 Node 的环境变量
|
||||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||||
// 一些 npm/终端 会设置 ELECTRON_NO_ASAR,也清掉
|
||||
delete process.env.ELECTRON_NO_ASAR;
|
||||
|
||||
const electronExe = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'node_modules',
|
||||
'electron',
|
||||
'dist',
|
||||
'electron.exe'
|
||||
);
|
||||
|
||||
const args = [path.join(__dirname, '..'), ...process.argv.slice(2)];
|
||||
|
||||
console.log('Starting Electron:', electronExe);
|
||||
console.log('Args:', args);
|
||||
|
||||
const child = spawn(electronExe, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: false,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
// spawn 在可执行文件不存在(npm install 没跑 / 路径损坏)时只触发 'error',
|
||||
// 不会触发 'exit'——不显式监听会让脚本静默挂起,用户只能强退。
|
||||
console.error(`启动 Electron 失败:${err.message}`);
|
||||
console.error(`请确认已运行 npm install,路径:${electronExe}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
// Electron 进程被信号杀掉时 code === null、signal 是 'SIGTERM' / 'SIGINT' 等,
|
||||
// 旧实现会静默吞掉、显示成「成功退出 0」,调试 SIGTERM 路径会很迷惑。
|
||||
if (signal) console.log(`Electron exited via signal: ${signal}`);
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
Reference in New Issue
Block a user