This commit is contained in:
2026-09-12 14:01:15 +08:00
commit b178c86ac7
11 changed files with 9658 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

117
README.md Normal file
View 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}` 反斜杠保留字面量
- 明暗主题、窗口透明度60100%)、粘贴时是否隐藏悬浮窗
- 侧栏可折叠(`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

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

907
main.js Normal file
View 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_MShide → keybd_event 之间的离场时间,避免 Ctrl+V 误发给「正在
// 隐藏中」的本窗口hide 是异步的,立即 sendCtrlV 可能撞上)
// PASTE_RESTORE_DELAY_MSsendCtrlV → 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();
// 同 showFloatingshowInactive 会重置层级,必须按当前偏好重新应用
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

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View 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
View 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
View 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.icoChromium 内核会自动挑合适尺寸。 -->
<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

File diff suppressed because it is too large Load Diff

1939
renderer/styles.css Normal file

File diff suppressed because it is too large Load Diff

48
scripts/start.js Normal file
View 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);
});